工具、中间件与技能:让 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:243 |
FunctionInvocationLayer | 套在 ChatClient 外的循环层:模型每次要求调工具,它就执行、回灌、再问,直到模型给文本答案或撞上预算上限 | python/packages/core/agent_framework/_tools.py:2355 |
| 三级中间件管道 | 在 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:1146(tool),它最终就是 return FunctionTool(name=..., func=f, input_model=schema, ...)(_tools.py:1290)。参数描述来自 Annotated[str, "城市名"] 里的那个字符串,由 _parse_annotation 转成 Pydantic 的 Field(description=...)(_tools.py:1010)。
@tool 支持三种写法,一律有效:
| 写法 | 效果 |
|---|---|
@tool | 直接裸装饰,名字取 func.__name__,描述取 docstring |
@tool(approval_mode="always_require") | 带参数,声明「调这个工具前必须人工批准」 |
@tool(schema=MyPydanticModel) | 显式给 schema,跳过从签名推断 |
3.3 FunctionTool 的关键设计点
FunctionTool.__init__(_tools.py:301)里藏着几个不显然但重要的决定:
(1) 返回值统一成 list[Content]。 不管你的函数返回 str、dict、图片还是 Pydantic 对象,invoke() 最后都过一遍 parse_result(_tools.py:828)转成统一的 Content 列表——这样上层不用关心工具到底返回了什么类型。想拿原始返回值,传 skip_parsing=True 或用 SKIP_PARSING 哨兵(_tools.py:124)。
(2) 声明式工具(declaration-only)。 func=None 时工具只有「声明」没有「实现」(declaration_only 属性,_tools.py:444)。模型能看到它、能点名它,但框架不会执行——用于「让模型推理该用哪个工具,但实际由客户端渲染/外部系统执行」的场景。
(3) 调用预算长在工具实例上。 max_invocations / max_invocation_exceptions 是整个工具实例生命周期的计数器,不自动重置(__call__ 里 self.invocation_count += 1,_tools.py:532)。对模块级单例工具要小心:计数会跨请求累加。想要「每请求」限额,应该用下面的 FunctionInvocationConfiguration["max_function_calls"]。
(4) 上下文注入。 如果工具函数的某个参数类型标成 FunctionInvocationContext,框架会自动 把上下文注进去而不是当成模型参数(_discover_injected_parameters,_tools.py:411)。这让工具能在运行时反过来操作 agent(比如动态加工具,见 §5.4)。
3.4 工具列表怎么规整:normalize_tools 与 _append_unique_tools
用户传进来的 tools=[...] 五花八门:裸函数、FunctionTool、MCPTool、dict、甚至「工具集合」对象。两个工具函数负责把它们理成一条干净列表:
| 函数 | 职责 | 位置 |
|---|---|---|
normalize_tools | 把裸 callable 转成 FunctionTool;把「工具集合」(如 toolbox)摊平;已是工具对象的原样放行 | _tools.py:941 |
_append_unique_tools | 按 name 去重合并;同名不同对象 → 抛错;同名同对象 → 跳过 | _tools.py:901 |
去重按名字进行(_get_tool_name,_tools.py:130)。这条规则很关键:MCP 服务器可能带来重名工具,框架会提示你给 MCPTool 设 tool_name_prefix(_agents.py:1255)。