数据截至 (上游 commit 1f738cdeb7f5)
工具、中间件与技能:让 agent 长出手脚
30 秒导读: 大模型本身只会说话——包括说出「我想调用
get_weather(location="Seattle")」。它并不能真的去查天气。这一章讲的就是把「模型说的那句话」变成「真实发生的函数调用」的那一层:怎么把 Python 函数包成工具、怎么用一个循环反复「调用→回灌结果→再问模型」、怎么用中间件在每次调用前后插入横切逻辑(日志、缓存、审批、安全),以及 MCP 远端工具和 Agent Skills 怎么接进同一套机制。
本章聚焦「工具怎么被调用、怎么被拦截扩展」。不涉及 workflow 图引擎(见 03-workflow-engine.md),run 循环与 ChatClient 的关系见 01-agent-abstraction.md。
1. 这是什么(零基础也能懂)
1.1 一句话定义
工具(tool)= 一段被包装过、可以交给模型「点名调用」的真实代码;这一层负责把模型的「点名」精确落到那段代码上,并把返回值转回给模型继续推理。
1.2 为什么需要它
模型给你的永远只是文本。当你希望 agent「查数据库」「改文件」「调外部 API」时,模型能做的仅仅是输出一段结构化文本:「请调用名为 X 的函数,参数是 {...}」。真正的执行必须由框架来做。
这中间有四件脏活,谁做 agent 谁躲不掉:
| 脏活 | 说明 |
|---|---|
| 名字→函数 | 模型给的是字符串名字,得查到对应的真实 Python 函数 |
| 参数校验 | 模型给的参数是 JSON,可能缺字段、类型错,要用 schema 校验后再喂给函数 |
| 结果回灌 | 函数返回值要转成模型看得懂的格式,拼回对话里,再问模型「拿到结果了,接下来呢」 |
| 反复循环 | 模型可能连续调好几轮工具才给最终答案,得有个循环兜住,还要防它无限打转 |
Microsoft Agent Framework 把这四件事收敛进一个叫 FunctionInvocationLayer 的组件——它是一个套在 ChatClient 外面的装饰层,自动跑完整个「工具调用循环」。
1.3 用起来什么样
最小例子:定义一个工具,挂到 agent 上,问一句话,框架就自动完成「模型点名→执行→回灌→模型作答」。
# 示意,非源码
from agent_framework import tool
from typing import Annotated
@tool # 把普通函数变成一个可被模型调用的工具
def get_weather(location: Annotated[str, "城市名"]) -> str:
"""查询某地天气。""" # docstring 会变成工具描述给模型看
return f"{location}:22°C 晴"
# 把工具交给 agent(agent 内部把它塞进 ChatClient 的工具列表)
agent = SomeChatClient(...).create_agent(tools=[get_weather])
reply = await agent.run("西雅图天气怎么样?")
# 你不用手写任何"调用 get_weather"的代码 —— 循环层自动做了
1.4 一句话直觉
把模型想成一个只能动嘴、不能动手的大脑。工具是「手脚」,中间件是「神经反射弧」(在信号传到手之前拦一道),而这一整章讲的就是大脑和手脚之间的那根脊髓——怎么把「我要抓杯子」这句话,可靠地变成手真的抓住了杯子。
2. 顶层全景(它大概怎么转)
2.1 三个主角
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
FunctionTool | 把一个 Python 函数包成工具:自动生成参数 JSON Schema、做校验、把返回值转成统一的 Content | python/packages/core/agent_framework/_tools.py:253 |
FunctionInvocationLayer | 套在 ChatClient 外的循环层:模型每次要求调工具,它就执行、回灌、再问,直到模型给文本答案或撞上预算上限 | python/packages/core/agent_framework/_tools.py:3036 |
| 三级中间件管道 | 在 agent 级 / function 级 / chat 级三个位置各插一道拦截,做日志、缓存、审批、安全 | python/packages/core/agent_framework/_middleware.py |
2.2 主线走一遍(高层,不进代码)
「怎么读这张图」:从上往下是一次 agent.run() 的控制流;虚线框是可选的拦截点;FunctionInvocationLayer 内部的循环是本章的心脏。
agent.run("西雅图天气?")
│
▼
┌───────────────────────┐ ← 拦截点 A:Agent 中间件
│ AgentMiddlewareLayer │ (整次 run 的前后)
└───────────┬───────────┘
▼
┌───────────────────────┐ ← 拦截点 C:Chat 中间件
│ ChatMiddlewareLayer │ (每次发给模型前后改消息)
└───────────┬───────────┘
▼
┌─────────────────────────────────────────────┐
│ FunctionInvocationLayer(工具调用循环) │
│ │
│ ①问模型 ──► 模型说"调 get_weather" ──┐ │
│ ▲ ▼ │
│ │ ┌──────────────┐│ ← 拦截点 B:Function 中间件
│ │ │ 执行工具 ││ (每个工具调用前后)
│ │ ④回灌结果 └──────┬───────┘│
│ └───────────────────────────────┘ │
│ ②③ 校验参数 / approval / 预算检查 │
└─────────────────────────────────────────────┘
│
▼
模型给出文本答案 → 返回给调用者
三个拦截点(A/B/C)不是同一个东西,而是同一份 middleware=[...] 列表被自动分成三类、分别装到三层上——这是 §5 的重点。
3. 工具层:把函数变成模型能调的东西
3.1 它要解决的小问题
模型只认识 JSON Schema 描述的函数签名。所以我们要把一个带类型注解的 Python 函数,自动翻译成一份 {"name":..., "parameters": {JSON Schema}},并且在模型真调用时把 JSON 参数校验回 Python 值。
3.2 @tool 装饰器:一行变工具
tool() 装饰器读函数签名,用 Pydantic 现造一个 model 来描述参数,再包成 FunctionTool。
真实实现见 _tools.py:1162(tool),它最终就是 return FunctionTool(name=..., func=f, input_model=schema, ...)(_tools.py:1306)。参数描述来自 Annotated[str, "城市名"] 里的那个字符串,由 _parse_annotation 转成 Pydantic 的 Field(description=...)(_tools.py:1026)。
@tool 支持三种写法,一律有效:
| 写法 | 效果 |
|---|---|
@tool | 直接裸装饰,名字取 func.__name__,描述取 docstring |
@tool(approval_mode="always_require") | 带参数,声明「调这个工具前必须人工批准」 |
@tool(schema=MyPydanticModel) | 显式给 schema,跳过从签名推断 |
3.3 FunctionTool 的关键设计点
FunctionTool.__init__(_tools.py:311)里藏着几个不显然但重要的决定:
(1) 返回值统一成 list[Content]。 不管你的函数返回 str、dict、图片还是 Pydantic 对象,invoke() 最后都过一遍 parse_result(_tools.py:844)转成统一的 Content 列表——这样上层不用关心工具到底返回了什么类型。想拿原始返回值,传 skip_parsing=True 或用 SKIP_PARSING 哨兵(_tools.py:134)。
(2) 声明式工具(declaration-only)。 func=None 时工具只有「声明」没有「实现」(declaration_only 属性,_tools.py:454)。模型能看到它、能点名它,但框架不会执行——用于「让模型推理该用哪个工具,但实际由客户端渲染/外部系统执行」的场景。
(3) 调用预算长在工具实例上。 max_invocations / max_invocation_exceptions 是整个工具实例生命周期的计数器,不自动重置(__call__ 里 self.invocation_count += 1,_tools.py:542)。对模块级单例工具要小心:计数会跨请求累加。想要「每请求」限额,应该用下面的 FunctionInvocationConfiguration["max_function_calls"]。
(4) 上下文注入。 如果工具函数的某个参数类型标成 FunctionInvocationContext,框架会自动把上下文注进去而不是当成模型参数(_discover_injected_parameters,_tools.py:421)。这让工具能在运行时反过来操作 agent(比如动态加工具,见 §5.4)。
3.4 工具列表怎么规整:normalize_tools 与 _append_unique_tools
用户传进来的 tools=[...] 五花八门:裸函数、FunctionTool、MCPTool、dict、甚至「工具集合」对象。两个工具函数负责把它们理成一条干净列表:
| 函数 | 职责 | 位置 |
|---|---|---|
normalize_tools | 把裸 callable 转成 FunctionTool;把「工具集合」(如 toolbox)摊平;已是工具对象的原样放行 | _tools.py:957 |
_append_unique_tools | 按 name 去重合并;同名不同对象 → 抛错;同名同对象 → 跳过 | _tools.py:917 |
去重按名字进行(_get_tool_name,_tools.py:140)。这条规则很关键:MCP 服务器可能带来重名工具,框架会提示你给 MCPTool 设 tool_name_prefix(_agents.py:1453)。
4. 自动工具调用循环:本章的心脏
4.1 它要解决的小问题
模型一次回复里可能要求调好几个工具,拿到结果后可能还要再调下一轮。得有个循环:问模型→(有工具调用吗?)→并发执行→把结果拼回对话→再问模型……直到模型给出纯文本答案。同时还要防三件事:无限循环、烧钱、连续报错。
4.2 循环长什么样
FunctionInvocationLayer.get_response(_tools.py:3561)内部的 _get_response() 就是这个循环。「怎么读这张图」:每一圈是一次 LLM 往返(iteration),圈内可能并发跑多个工具。
attempt_idx = 0
│
▼
┌────────────────────────────────────────────┐
│ for attempt in range(max_iterations=40): │ ← 默认 40 圈上限
│ │
│ ①先处理上一轮遗留的审批响应 │ _process_function_requests(prepped)
│ ②调底层模型 super().get_response(...) │
│ ③response 里有 function_call 吗? │
│ 否 ────────── ────► return 文本答案 │
│ 是 │
│ ▼ │
│ ④并发执行所有工具调用 │ _try_execute_function_calls
│ ⑤把 function_result 拼成一条 tool 消息 │ _handle_function_call_results
│ ⑥预算/错误检查: │
│ · 累计调用数 ≥ max_function_calls? │ → tool_choice="none" 逼模型收尾
│ · 连续错误 ≥ 3? │ → 停
│ continue(回到②) │
└────────────────────────────────────────────┘
│ 循环耗尽 40 圈仍没收敛
▼
再问模型一次、但 tool_choice="none",逼它出纯文本,避免留下"孤儿"工具调用
4.3 三个配置旋钮(容易搞混)
FunctionInvocationConfiguration(_tools.py:1327)是这个循环的控制面板。最容易混的是前两个:
| 配置项 | 限的是什么 | 默认 |
|---|---|---|
max_iterations | LLM 往返次数(圈数)。一圈里并发调 10 个工具也只算 1 | 40(_tools.py:95) |
max_function_calls | 工具执行总次数(跨所有圈累加)。控成本的主旋钮 | None(无限) |
max_consecutive_errors_per_request | 连续报错多少次就放弃工具循环 | 3 |
terminate_on_unknown_calls | 模型点名了不存在的工具时,是否直接抛错 | False |
max_function_calls 是尽力而为的限制:它在每批并发调用跑完之后才检查(新版把判定提成 _function_call_limit_reached,_tools.py:2709-2710)。所以如果模型一圈里要 20 个并发调用而上限是 10,这 20 个仍会全跑完,然后循环才停。停的方式是把 tool_choice 改成 "none"(_disable_tools_at_function_call_limit,_tools.py:2731-2743),逼模型下一次必须出文本。
4.4 一次工具执行,从请求到生效
自顶向下四个函数,层层收窄:
_process_function_requests (_tools.py:2240) 编排:抽取 function_call、处理审批、决定 continue/return/stop
│
▼
_try_execute_function_calls (_tools.py:1625) 分诊 + 并发:哪些要审批?哪些是声明式?其余 asyncio.gather 并发
│
▼
_auto_invoke_function (_tools.py:1391) 单个调用:查 tool_map、校验参数、走中间件管道、拿结果
│
▼
FunctionTool.invoke (_tools.py:577) 真执行:再校验、跑函数(同步函数丢到线程)、parse_result
几个真源码里的关键判断:
- 参数两道校验。 先用工具的 Pydantic
input_model校验(_auto_invoke_function里model_validate,_tools.py:1521),再过一遍轻量 schema 检查_validate_arguments_against_schema(_tools.py:1080)。JSON-schema 直供的工具(如 MCP)走后者。 - 同步函数不阻塞事件循环。
_invoke_function把非 async 的工具丢进asyncio.to_thread(_tools.py:562)。 - 报错默认不泄露细节。 工具抛异常时,回给模型的是笼统的
"Error: Function failed.";只有开include_detailed_errors才带上异常详情(_tools.py:1421)。这是防「把内部栈信息喂给模型」。
4.5 结果怎么打包:_FunctionProcessingResult
每处理完一步,循环需要知道「下一步干嘛」。旧版用一个 FunctionRequestResult TypedDict,新版换成等价的 dataclass _FunctionProcessingResult(_tools.py:2771-2781),同样用 action 字段驱动状态机:
action | 含义 |
|---|---|
"continue" | 有工具结果,回灌后再问模型 |
"return" | 收工:模型给了文本,或出现了要用户接管的审批/声明式调用 |
"stop" | 撞上连续错误上限,强制收尾 |
_handle_function_call_results(_tools.py:2811)决定填哪个 action。特别地:一旦发现结果里有 function_approval_request 或声明式 function_call,它立刻 return——把控制权交还给调用者去找用户拿批准(接 §6)。
5. 中间件:在请求/响应/异常处插横切逻辑
5.1 它要解决的小问题
日志、缓存、限流、审批、安全检查——这些逻辑不属于任何单个工具,却要围绕工具调用发生。中间件让你在「调用前」和「调用后」各插一段代码,而不用改工具本身。
5.2 三级管道:同一份列表,三处生效
这是最容易看晕的设计。你只写一个 middleware=[a, b, c],框架会按每个中间件吃的上下文类型把它们自动分成三类,装到三个不同的层上:
| 中间件类型 | 吃的上下文 | 拦在哪 | 典型用途 |
|---|---|---|---|
| Agent 级 | AgentContext | 整次 run() 外围 | 重试、整体计时、改输入消息 |
| Chat 级 | ChatContext | 每次发给模型前后 | 加系统提示、数 token、改 options |
| Function 级 | FunctionInvocationContext | 每个工具调用前后 | 缓存、参数校验、审批、安全策略 |
分类由 categorize_middleware(_middleware.py:1513)完成。它怎么知道一个中间件是哪级?看两个信号(_determine_middleware_type,_middleware.py:1438):
- 装饰器标记:
@agent_middleware/@function_middleware/@chat_middleware(给函数打_middleware_type属性)。 - 第一个参数的类型注解:是
AgentContext还是FunctionInvocationContext还是ChatContext。
两个信号都在且冲突 → 抛错;都没有 → 抛错要求你二选一。类式中间件(继承 AgentMiddleware 等抽象基类)则直接靠 isinstance 判断(_middleware.py:1537)。
5.3 三层怎么套在一起
分好类后,三层通过 Python 的 MRO(多继承)套娃:AgentMiddlewareLayer(_middleware.py:1254)在最外,它把 function+chat 中间件通过 client_kwargs["middleware"] 往下传给 ChatClient;FunctionInvocationLayer.__init__(_tools.py:3034)再调一次 categorize_middleware,把 function 级留给自己、chat 级继续往上传给 ChatMiddlewareLayer。
每一层的执行引擎都是同一个模式——create_next_handler(index) 递归构造「洋葱圈」调用链。看 FunctionMiddlewarePipeline.execute(_middleware.py:963):
# 示意,非源码:洋葱模型的核心
def create_next_handler(index):
if index >= len(middlewares): # 到底了,执行真正的函数
return lambda: run_the_actual_tool()
async def handler():
# 当前中间件拿到"下一个handler"作为 call_next
await middlewares[index].process(context, create_next_handler(index + 1))
return handler
await create_next_handler(0)() # 从第 0 个开始,层层向内
中间件的 process(context, call_next) 里:await call_next() 之前的代码是「请求阶段」,之后的是「响应阶段」——和 Web 框架的中间件一个套路。数据全部挂在 context 上流动,process 本身不返回值(_middleware.py:565)。