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(计费 / 技术),专家查数据、给出回复。

这个小系统恰好用到本章每一个部件。先看它的整体形状,后面每节再把其中一块拆开讲透。

用户消息 分诊 Agent 分类意图 handoff 专家 Agent 计费 / 技术 回复 同一个 AgentThread 贯穿全程
图 1.1分诊系统的数据流。 注意:每个 Agent 方块内部 = Chat Client 调模型 + Tools 取数据 + (本次输入读自) Thread;两个 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 没有的职责。

chat_client.pyPython
# 说明性示例,展示概念形状,未在本机运行
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。
洞察 · 第一个门槛:Agent 无状态

后端工程师的直觉是"对象会记住自己的状态"。MAF 反过来:Agent 是纯逻辑,会话状态在外部的 thread 里。这一条现在看像个细节,但 server-managed thread、水平扩展、为什么能把同一个 agent 实例并发服务上千会话——全都从这里推出来。把它记牢。

AgentThread 对话历史(外部) Agent.run() 调用 LLM 执行 Tools 要调工具 返回结果 循环到模型不再要求调工具 ① 读历史 ② 写回 最终答复 ③ 返回
图 1.2一次 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 参数、调用你的函数、把返回值序列化回消息塞进循环。结论:类型注解不是装饰——它直接决定模型看到的参数类型,注解写错或缺失,模型就更容易传错类型的参数。

tools.pyPython
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 有两种持久化模型,这是设计核心,不是实现细节:

表 1.1 · 两种 Thread 持久化
模型历史存在哪适用场景
client-managedthread 自己持有完整消息,放进可插拔的 ChatMessageStore(内存 / Redis / Cosmos)自管会话、要完全掌控历史与存储
server-managedthread 只持有一个远端 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.pyPython
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)。重心是"数据在图里流动 + 可恢复"。
编排多 Agent AgentChat 模式 Sequential · 顺序传递 Concurrent · 并行扇出聚合 Handoff · 交出控制权 GroupChat · 轮流发言 Magentic · LLM 动态规划 重心:传消息 · 偏非确定 Workflow 图引擎 Executors + Edges(有向图) Pregel 超步 + 同步屏障 超步边界检查点(可恢复) 共享状态 · HITL 人工介入 类型 / 条件路由 重心:数据流图 · 确定性 对话式 图式 底层:actor 运行时 → 02 章 都运行于
图 1.3编排的两副面孔并列。 注意: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

先合上教程,把答案写在纸上或编辑器里。写完再点开对照——直接点开等于把这一节当再读一遍。

  1. Chat Client 和 Agent 各自有没有状态?用一句话说清它们的职责差异。
  2. "MAF 的 agent 默认是多轮工具循环"——这一句对成本意味着什么?
  3. 同一个 agent 实例为什么能并发服务上千个互不相干的会话?(把答案落到"哪个对象持有状态"上。)
  4. client-managed 与 server-managed 两种 thread,各自把历史存在哪、各自禁止什么?
  5. (设计题)为什么 MAF 要提供 AgentChat 模式 和 Workflow 图引擎两套编排,而不是一套?
答案(先做完再展开)
  1. Chat Client 无状态,是纯模型连接器;Agent 也无状态,但带指令和工具,是执行单元;两者都不存会话历史。差异:Client 管"怎么连模型",Agent 管"怎么用模型 + 工具完成一轮任务"。
  2. 一次 run() 可能多次往返模型、多次执行工具,所以单次调用的 token 成本和延迟可能远超"一问一答"的直觉,需要为循环次数设预算 / 上限。
  3. 因为状态不在 agent 里,而在外部 thread 里。每个会话带自己的 thread,agent 只是无状态逻辑,可被任意并发复用。
  4. client-managed:历史在 thread 自身,放进可插拔的 ChatMessageStore(内存 / Redis / Cosmos),进程自管。server-managed:历史在服务端,thread 只留远端 ConversationId;它禁止本地保存历史。
  5. 两端需求不同:简单串联要轻量、低仪式感,对话式最合适;生产流程要确定性、审计、断点续跑、类型校验,需要图式。一套无法同时做到轻与重,所以并存。代价是部分模式两边重复出现(冗余)。
进阶挑战 · 刚好够不着

把分诊场景的"记忆"画清楚

分诊 agent 把对话 handoff 给计费专家后,计费专家能看到用户最开始说的那句"我被多扣了钱"吗?把 §1.2 的 agentic loop 图和 §1.4 的 thread 模型拼起来想:handoff 时传过去的是什么,才决定了专家能不能看到历史。

提示(卡住再展开)

关键不在"agent 换了",而在"thread 有没有跟着传过去"。如果 handoff 携带同一个 thread,历史就在;如果新起一个 thread,专家就是失忆开局。记忆始终是 thread 的属性——这一章反复钉的那句话。具体 handoff 怎么携带上下文,02 章的运行时机制会讲。