Chapter 01
核心概念:从一个客服分诊系统认全部件
起点页给出了 MAF 的全貌和一句话本质——Agent 无状态、编排分两套运行时。这一章把全貌拆成可命名的部件: Chat Client、Agent、Tools、AgentThread、Middleware,以及两套编排面。每个概念用同一个客服分诊场景串起来,讲清"它是什么、为什么要它、底层怎么转"。
本章你将建立的 schema
- 单个 agent 的四个部件:Chat Client(连模型)、Tools(连外部)、AgentThread(连历史)、Middleware(连横切关注点)。
- 第一个门槛:Agent 是无状态执行单元,状态在外部 Thread 里——这与"对象会记住状态"的直觉相反。
- 第二个门槛的入口:编排有两副面孔——AgentChat 模式 与 Workflow 图引擎。
1.0贯穿全章的场景:客服分诊
抽象概念悬在空中很难记。整章用一个具体系统做锚:一个客服分诊服务。用户发来一句话(比如"我上个月被多扣了一笔费用"),系统先由一个分诊 agent 判断意图,再把对话交给对应的专家 agent(计费 / 技术),专家查数据、给出回复。
这个小系统恰好用到本章每一个部件。先看它的整体形状,后面每节再把其中一块拆开讲透。
AgentThread——状态不在任何一个 agent 盒子里。1.1Chat Client:模型连接器
把"对话请求"翻译成某个 LLM provider 的 API 调用,并把响应翻回框架统一的消息类型。
OpenAI、Azure OpenAI、Anthropic、Bedrock、Gemini、Ollama 的 HTTP 接口形状各不相同。没有这一层,换 provider 要改业务代码。Chat Client 把 provider 差异关进一个对象,业务侧只面对统一的 message + tool 接口。
底层机制(比文档深一层):Chat Client 不只是 HTTP 封装。它实现一个统一的 chat 接口——输入是 messages + tools + options,输出是带或不带 tool-call 的 response——并把 provider 特有的能力(流式、structured output、function-calling 的报文格式)适配成框架的统一表示。.create_agent(...) 是工厂方法:它把"模型连接"与"agent 的指令 / 工具"绑在一起,产出一个 ChatAgent。换句话说,Chat Client 自身是无状态的纯连接器,带指令的执行单元是它造出来的 Agent。
像数据库的 driver(JDBC):屏蔽底层差异,上层只用统一接口。边界:driver 只搬数据;Chat Client 还得把 function-calling 这种"模型反过来要调你函数"的半结构化协议适配统一——这是普通 driver 没有的职责。
# 说明性示例,展示概念形状,未在本机运行
from agent_framework.openai import OpenAIChatClient
client = OpenAIChatClient(model_id="gpt-4.1") # 纯连接器,无状态
triage = client.create_agent( # 工厂方法:连接 + 指令 -> Agent
name="triage",
instructions="判断用户意图:billing 还是 technical。",
)
与下一个概念的关系:Chat Client 产出的那个东西——Agent——才是本章的主角,也是第一个门槛。
1.2Agent / ChatAgent:无状态执行单元
一个带指令和工具的 LLM 执行单元;它本身不记对话,每次 run 都要喂入会话状态。
裸调 LLM API,你得自己管这个循环:调模型 → 模型要求调工具 → 执行工具 → 把结果喂回去 → 再调模型 → 直到模型给最终答复。Agent 把这个 agentic loop 封装好,你只管喂输入、拿结果。
底层机制(比文档深一层):run() 内部跑一个循环。把(指令 + thread 历史 + 本次输入)发给模型;若模型返回 tool calls,框架逐个 dispatch 到你的函数,把工具结果作为新消息追加;再发给模型;直到模型返回不带 tool call 的最终答复。两个后端工程师必须记住的点:
- 默认是多轮工具循环(multi-turn)。不像 AutoGen v0.2 的单轮——一次
run()往返模型的次数不定,取决于模型要调几轮工具。成本和延迟容易超出"一次问答"的直觉。 - Agent 对象不持有这个循环产生的历史。历史进 thread,不进 agent。
后端工程师的直觉是"对象会记住自己的状态"。MAF 反过来:Agent 是纯逻辑,会话状态在外部的 thread 里。这一条现在看像个细节,但 server-managed thread、水平扩展、为什么能把同一个 agent 实例并发服务上千会话——全都从这里推出来。把它记牢。
run() 内部:LLM 与 Tools 之间反复循环。
注意:历史从左边 thread 读入、最后写回——Agent 盒子里没有"记忆",它只是借用外部 thread。不传 thread,这次 run 就是一段失忆的全新对话。同一个 agent 对象,连续调用 run() 两次,但都不传 thread。第二次它记得第一次说过什么吗?
展开答案(先停 10 秒)
不记得。每次不带 thread 的 run() 都是一段全新对话——agent 无状态,没有 thread 就没有历史可读。要连续多轮,必须把同一个 thread 传进每一次 run。
这正是图 1.2 想钉死的一点:记忆是 thread 的属性,不是 agent 的属性。
与下一个概念的关系:Agent 的"手"是工具——它怎么知道有哪些工具可调、又怎么把模型的请求接到你的 Python 函数上?
1.3Tools / @ai_function:把函数交给模型
用装饰器把普通函数暴露给模型;框架从类型注解自动生成 JSON schema。
function-calling 协议要求你给模型一份工具清单的 JSON schema(函数名、参数、类型)。手写这份 schema 既啰嗦,又容易和真实函数签名漂移。@ai_function 从签名 + docstring 自动生成,签名变了 schema 跟着变。
底层机制(比文档深一层):装饰器在注册时反射函数签名(参数名、类型注解、默认值)和 docstring,生成符合 provider function-calling 格式的 schema。运行时模型返回一个 tool call(函数名 + JSON 参数),框架按名查表、把 JSON 反序列化成 Python 参数、调用你的函数、把返回值序列化回消息塞进循环。结论:类型注解不是装饰——它直接决定模型看到的参数类型,注解写错或缺失,模型就更容易传错类型的参数。
from agent_framework import ai_function
@ai_function
def lookup_invoice(invoice_id: str) -> str:
"""按发票号查询金额与状态。""" # docstring 进入 schema 的 description
... # invoice_id 的类型注解 -> schema 的参数类型
billing = client.create_agent(
name="billing",
instructions="处理计费问题,必要时查发票。",
tools=[lookup_invoice], # 工具装配到 agent 上
)
像 web 框架的路由装饰器(@app.get):声明式地把函数登记到一个调度表。边界:路由是给前端 / 人调用的;这里是给 LLM 调用,且 LLM 会自己决定调不调、传什么参数——你无法假设入参一定合法。
与下一个概念的关系:Agent 无状态(§1.2),工具是它的手,那"记住上一句话"这件事到底落在哪个对象上?
1.4AgentThread:显式的会话状态
显式的会话状态对象——保存一段对话的消息历史,跨多次 run 传递,可序列化、可换存储后端。
Agent 无状态(§1.2),所以"记住上一句"必须有地方放。Thread 就是那个地方。把状态从 agent 里拆出来,带来一个直接好处:同一个 agent 实例可以并发服务成千上万个互不相干的会话,每个会话拿自己的 thread。
底层机制(比文档深一层):Thread 有两种持久化模型,这是设计核心,不是实现细节:
| 模型 | 历史存在哪 | 适用场景 |
|---|---|---|
| client-managed | thread 自己持有完整消息,放进可插拔的 ChatMessageStore(内存 / Redis / Cosmos) | 自管会话、要完全掌控历史与存储 |
| server-managed | thread 只持有一个远端 ConversationId,历史在服务端(如 Azure AI Foundry Persistent Agents) | 用托管 agent 服务,历史不落本地 |
这两条决定了三件大事:能不能水平扩展、对话能不能跨进程恢复、谁为历史存储付费。还有个硬约束:server-backed 的 agent 禁止本地历史——thread 退化成一个纯远端指针,持久化策略由 chat client 决定,你没得选。
现在两块合上:Agent(无状态逻辑) + Thread(显式状态)。这一对组合,正是 MAF 区别于 AutoGen v0.2 / v0.4 "有状态 agent"的根本处——也是 02 章讲 actor 运行时、水平扩展时反复回来踩的地基。
thread = triage.get_new_thread() # 显式创建会话状态
await triage.run("我被多扣了一笔钱", thread=thread) # 第 1 轮
await triage.run("就上个月那笔", thread=thread) # 第 2 轮,带着同一个 thread -> 记得上一句
一个无状态 web 服务,每个请求新建 agent,但要支持用户多轮连续对话。thread 该存哪里?为什么不能存进程内存?
展开答案(先停 10 秒)
不能存进程内存——下一个请求可能被负载均衡打到另一个实例,内存里的 thread 就丢了。两个可行解:① client-managed + 外部 ChatMessageStore(如 Redis),按会话 id 取回 thread;② server-managed,历史交给托管服务,本地只留 ConversationId。
设计洞察:正因为 Agent 无状态、Thread 可序列化又可换后端,水平扩展才成立。有状态 agent 做不到这点。
与下一个概念的关系:日志、鉴权、PII 脱敏这些横切关注点,不该散进每个工具里——它们落在 middleware 这一层。
1.5Middleware:包在调用外面的洋葱层
可链式的处理器,包住 agent run 和函数调用,集中处理日志、鉴权、限流、护栏。
PII 脱敏、调用计时、限流、权限校验是横切关注点。塞进每个工具里会重复且易漏。Middleware 把它们抽成一层,统一套在调用链外面。
底层机制(比文档深一层):两类 middleware——function middleware(包每次工具调用)和 chat / agent middleware(包每次 agent run 或模型调用)。结构是洋葱模型:请求逐层向内穿到核心(模型 / 工具),响应再逐层向外穿回。每层都能读改请求、短路直接返回、记录耗时。于是护栏可以在请求进模型前拦截(脱敏 PII),也可以在工具结果回来后过滤敏感字段。这一套是 Semantic Kernel 带进 MAF 的企业基因。
几乎就是 ASP.NET / Express 的 middleware pipeline,概念一对一。边界:web middleware 包的是 HTTP 请求;这里包的是"agent 跑一轮"和"调一次模型 / 工具",粒度落在 LLM 调用层。
与下一个概念的关系:单个 agent 的四个部件(连模型、连工具、连历史、连横切)齐了。把多个 agent 编起来——这里 MAF 露出第二个门槛:编排有两副面孔。
1.6两套编排面:AgentChat 模式 与 Workflow 图引擎
MAF 提供两种把多个 agent 组织起来的方式:轻量对话式的 AgentChat 模式,与显式图式的 Workflow。
不同任务对"确定性"和"灵活度"的需求不同。"先写后审"这种简单串联,用对话式最省事;要审计留痕、要断点续跑、要类型校验的生产流程,需要图式。一套撑不起两端,于是 MAF 同时提供两套。
底层机制(比文档深一层):
- AgentChat 模式:用 builder 把一组 agent 组织成
Sequential(顺序传递)、Concurrent(并行扇出再聚合)、Handoff(一个 agent 把控制权交给另一个)、GroupChat(多 agent 轮流发言)、Magentic(一个 LLM manager 动态规划)。重心是"agent 之间传消息",轻量、偏非确定。分诊场景里"分诊 → 交给专家"就是一次 handoff。 - Workflow 图引擎:用
WorkflowBuilder把Executor(工作单元,可以包一个 agent)用Edge(带类型、带条件)连成有向图,按 Pregel 超步推进,支持检查点、共享状态、人工介入(HITL)。重心是"数据在图里流动 + 可恢复"。
Handoff / GroupChat / Magentic 这些名字在两套里都会出现——这是收敛带来的冗余;但两套最终都落到同一个 actor 运行时上,这是 02 章的主线。多数人以为一个框架只有一种"编排"。MAF 有两套,且部分模式在两边都出现。先记住"有两套、各偏一端";什么时候用哪套留到 02 章——那需要先看清两者底下的运行时差异。
需求是:"把一篇稿子先给写作 agent、写完再给审稿 agent"。这用哪套编排?
展开答案(先停 10 秒)
Sequential(AgentChat 模式)就够——两步顺序传递,没有条件分支、不需要断点续跑。动用 Workflow 图引擎是杀鸡用牛刀。除非加上"审稿不过要打回重写、且整个流程要能中断后从上次位置恢复",那才值得上图引擎。判断依据 02 章给。
1.7协议与可观测:MCP / A2A / OpenTelemetry
最后三个部件,分量轻些,但要知道它们在图上的位置:
- MCP(Model Context Protocol,已 GA):让 agent 通过 MCP server 发现并调用外部工具。把"工具"从你进程内的
@ai_function扩展到任何 MCP 服务暴露的工具。机制上,agent 多了一个"远程工具来源",调用链和本地工具一样进 agentic loop。 - A2A(Agent-to-Agent,preview):agent 之间互操作的协议。截至 2026-06,MAF 的 A2A 绑定仍是 preview("即将支持 1.0")。能用来连别家的 agent,但别当生产依赖。
- OpenTelemetry:MAF 按 OpenTelemetry 的 GenAI 语义约定发出 traces / logs / metrics。这是 SK 带进来的可观测基因——agent 的调用链能直接接进现有 APM,不必另造一套监控。
把 A2A "当 GA 用"是当前最现实的失败模式之一。MCP 已 GA、可放心用;A2A 的 MAF 绑定还在 preview,接口和行为尚未稳定。生产里跨 agent 互操作,先确认你用的版本里 A2A 的状态。
§本章 self-check
先合上教程,把答案写在纸上或编辑器里。写完再点开对照——直接点开等于把这一节当再读一遍。
- Chat Client 和 Agent 各自有没有状态?用一句话说清它们的职责差异。
- "MAF 的 agent 默认是多轮工具循环"——这一句对成本意味着什么?
- 同一个 agent 实例为什么能并发服务上千个互不相干的会话?(把答案落到"哪个对象持有状态"上。)
- client-managed 与 server-managed 两种 thread,各自把历史存在哪、各自禁止什么?
- (设计题)为什么 MAF 要提供 AgentChat 模式 和 Workflow 图引擎两套编排,而不是一套?
答案(先做完再展开)
- Chat Client 无状态,是纯模型连接器;Agent 也无状态,但带指令和工具,是执行单元;两者都不存会话历史。差异:Client 管"怎么连模型",Agent 管"怎么用模型 + 工具完成一轮任务"。
- 一次
run()可能多次往返模型、多次执行工具,所以单次调用的 token 成本和延迟可能远超"一问一答"的直觉,需要为循环次数设预算 / 上限。 - 因为状态不在 agent 里,而在外部 thread 里。每个会话带自己的 thread,agent 只是无状态逻辑,可被任意并发复用。
- client-managed:历史在 thread 自身,放进可插拔的
ChatMessageStore(内存 / Redis / Cosmos),进程自管。server-managed:历史在服务端,thread 只留远端ConversationId;它禁止本地保存历史。 - 两端需求不同:简单串联要轻量、低仪式感,对话式最合适;生产流程要确定性、审计、断点续跑、类型校验,需要图式。一套无法同时做到轻与重,所以并存。代价是部分模式两边重复出现(冗余)。
把分诊场景的"记忆"画清楚
分诊 agent 把对话 handoff 给计费专家后,计费专家能看到用户最开始说的那句"我被多扣了钱"吗?把 §1.2 的 agentic loop 图和 §1.4 的 thread 模型拼起来想:handoff 时传过去的是什么,才决定了专家能不能看到历史。
提示(卡住再展开)
关键不在"agent 换了",而在"thread 有没有跟着传过去"。如果 handoff 携带同一个 thread,历史就在;如果新起一个 thread,专家就是失忆开局。记忆始终是 thread 的属性——这一章反复钉的那句话。具体 handoff 怎么携带上下文,02 章的运行时机制会讲。