第 5 章(上):Tool 如何与 LLM 交互
我遇到了什么问题
前四章解决了 Agent 的基础设施问题:
- 第 2 章:LLM 怎么接入(无状态 → 会话管理)
- 第 3 章:上下文从哪来(记忆 + 检索 + 规范)
- 第 4 章:怎么立规矩(Skill + Rule)
但这些都是"让 Agent 能说话"。用户真正需要的是"让 Agent 做事"。
问题 1:Agent 只能"说",不能"做"
用户说:"帮我创建一个 C# 模块。" Agent 回复了一段代码文本。
但用户要的不是"看一段代码",而是"帮我在项目里创建好文件、目录、配置"。Agent 需要工具——能操作文件系统、能调用 API、能执行命令。
问题 2:工具调用不是"一问一答"
给 Agent 加工具看起来简单——定义一个函数,让 LLM 调用就行了。但实际跑起来才发现,一次工具调用根本不是一问一答,而是一个循环:
用户提问 → 调 LLM → LLM 说"我要调工具A" → 执行工具A → 结果喂回 LLM
→ LLM 说"我还要调工具B" → 执行工具B → 结果喂回 LLM
→ LLM 终于给出最终回复这个循环手写起来全是坑。

Function Call 协议:大模型怎么告诉我要调工具
在动手实现之前,先搞清楚一个基础问题:大模型怎么“说”它要调工具?
目前主流模型(GPT-4o、Claude 3.5、Gemini、Qwen、DeepSeek、Llama 3)都支持 Function Call——大模型在输出文本的同时,可以额外输出一段结构化的“工具调用请求”。
这个协议已经成为事实标准。虽然各家 API 的细节略有差异,但核心流程一样:
第一步:告诉模型有哪些工具
你在请求里带上工具列表,每个工具包含名称、描述、参数 Schema:
json
// 请求中带上 tools
{
"model": "gpt-4o",
"messages": [{"role": "user", "content": "帮我创建一个 C# 模块"}],
"tools": [
{
"type": "function",
"function": {
"name": "create_module",
"description": "在项目中创建一个新的 C# 模块",
"parameters": {
"type": "object",
"properties": {
"module_name": { "type": "string", "description": "模块名称" },
"target_path": { "type": "string", "description": "目标路径" }
},
"required": ["module_name"]
}
}
}
]
}第二步:模型返回工具调用请求
模型不是直接输出文本,而是返回一个 tool_calls 结构:
json
// 模型响应
{
"choices": [{
"message": {
"role": "assistant",
"content": null, // ← 注意:没有文本内容
"tool_calls": [{ // ← 而是工具调用请求
"id": "call_abc123",
"type": "function",
"function": {
"name": "create_module",
"arguments": "{\"module_name\": \"Payment\", \"target_path\": \"src/Modules\"}"
}
}]
},
"finish_reason": "tool_calls" // ← 关键信号:模型要调工具
}]
}注意两个关键点:
content是null——模型没有要说的话,它要调工具finish_reason是"tool_calls"——不是"stop",说明对话没结束
第三步:执行工具,把结果喂回模型
你按 function.name 找到对应的工具,执行它,把结果作为 tool 角色消息塞回去:
json
// 把工具结果喂回模型
{
"messages": [
{"role": "user", "content": "帮我创建一个 C# 模块"},
{"role": "assistant", "tool_calls": [...]}, // ← 模型的工具调用请求
{"role": "tool", // ← 工具执行结果
"tool_call_id": "call_abc123",
"content": "模块 Payment 已创建在 src/Modules/Payment/"}
]
}第四步:模型看到结果,给出最终回复
模型看到工具结果后,生成最终文本回复:
json
{
"choices": [{
"message": {
"role": "assistant",
"content": "已为你创建了 Payment 模块,目录在 src/Modules/Payment/,包含 DomainService、Repository 和 Controller 三层。"
},
"finish_reason": "stop" // ← 这次是 stop,对话结束
}]
}协议的核心要点
整个协议就三件事:
1. 你告诉模型:有什么工具(tools + parameters schema)
2. 模型告诉你:我要调哪个(tool_calls + arguments)
3. 你告诉模型:结果是什么(tool message + content)模型可能一次调多个工具(多个 tool_calls),也可能调完一个还要调下一个(循环)。直到 finish_reason 变成 "stop",循环才结束。
这就是为什么上一节说“工具调用不是一问一答,是一个循环”——协议本身就设计成了循环结构。
各家模型的差异
虽然核心流程一样,但各家的细节有差异:
| 模型 | 协议名称 | 差异点 |
|---|---|---|
| OpenAI (GPT-4o) | Function Calling | 事实标准,其他家基本对齐 |
| Anthropic (Claude 3.5) | Tool Use | 参数用 JSON 对象而非字符串 |
| Google (Gemini) | Function Calling | 基本对齐 OpenAI |
| 阿里 (Qwen) | Function Calling | 完全对齐 OpenAI |
| DeepSeek | Function Calling | 完全对齐 OpenAI |
| Meta (Llama 3) | Function Calling | 开源模型中也支持 |
差异不大,但参数格式的细节不同——这就是为什么需要一个统一的抽象层。 微软的 Microsoft.Extensions.AI 就是干这个的——不管底层是 OpenAI 还是 Ollama,上层都用同一套 AIFunction 接口。
我做了什么决策
决策 1:Tool 系统——核心层不管工具,业务层按需注册
Agent 需要工具来操作外部世界。但工具从哪来?
有两种思路:
- 内置工具:把常用操作(文件读写、命令执行、HTTP 请求)内置到 Agent 核心
- 外部提供:核心层不管工具,由业务层注册
我选了后者。原因很简单:核心层不知道业务需要什么工具。写死在核心里,每次加新工具就要改核心代码。
csharp
// 工具提供者接口:核心层定义,业务层实现
public interface IAgentToolProvider
{
// 获取指定 Agent 类型可用的工具列表
IReadOnlyList<IAgentAiTool> GetToolsForAgent(string agentType);
// 获取 LeadAgent 可用的工具列表
IReadOnlyList<IAgentAiTool> GetToolsForLeadAgent();
// 按名称查找工具(用于监控/调试)
IAgentAiTool? GetToolByName(string name);
}注意 GetToolsForAgent(string agentType) 的设计——不同类型的 Agent 看到不同的工具集。
WorkflowPlanner Agent → 看到"节点查询"、"拓扑构建"工具
CodeRunner Agent → 看到"代码执行"、"沙箱管理"工具
LeadAgent → 看到"任务派发"工具 + 所有通用工具核心层注册默认空实现(无任何工具):
csharp
// 核心层:默认无工具
services.TryAddSingleton<IAgentToolProvider, DefaultAgentToolProvider>();
// Integration 层:注册自己的实现,覆盖默认
// services.AddSingleton<IAgentToolProvider, MyIntegrationToolProvider>();又是 Provider 可覆盖模式。核心层不关心有什么工具,业务层按需注册。
决策 2:Tool Call 循环——这个坑我卡了很久
工具定义好了,但工具怎么被 LLM 调用?这里有一个核心机制,我卡了很久。
手写循环的五个坑
- finish_reason 判断:LLM 返回
tool_calls还是stop?判断错了就死循环或提前退出 - 参数绑定:LLM 返回的是 JSON 字符串,要解析、要类型转换、要匹配到正确的函数
- 错误处理:工具执行失败了怎么办?直接抛异常还是把错误告诉 LLM 让它重试?
- 死循环防护:LLM 一直说"我要调工具"不停怎么办?
- Schema 匹配:你告诉 LLM 的工具参数格式,必须和框架实际接受的格式一致,否则 LLM 生成的参数解析不了
最后一个坑(Schema 匹配)我卡得最久,也是我觉得最值得讲的。
JSON Schema:Function Call 协议的命门
回看上面的 Function Call 协议,第一步是"告诉模型有哪些工具"——这个"告诉"用的就是 JSON Schema:
json
"parameters": {
"type": "object",
"properties": {
"module_name": { "type": "string" },
"target_path": { "type": "string" }
},
"required": ["module_name"]
}这个 Schema 是 LLM 生成工具调用参数的唯一依据。 LLM 不是"理解"你的 C# 代码,它是看着这份 JSON Schema 来生成参数的。Schema 写错了,LLM 生成的参数就错了——类型不匹配、字段名对不上、必填参数缺失。
所以 JSON Schema 的准确性,直接决定了 Tool Call 能不能跑通。
我卡住的地方:框架把"定义"和"执行"绑死了
微软的 AIFunctionFactory.Create 的设计假设是:工具 = C# 方法。你写一个 C# 方法,框架自动从方法签名推断 Schema:
csharp
// 框架期望的用法:C# 方法 → 自动推断 Schema
AIFunction func = AIFunctionFactory.Create(
(string moduleName, string targetPath) => { ... }
);
// 自动推断出:{"moduleName": "string", "targetPath": "string"}这在"工具都是你自己写的 C# 方法"时没问题。但我的工具来自插件系统——工具的参数定义是外部给的(OpenAPI 规范、JSON Schema),不是我先写一个 C# 方法再让框架推断。
我的场景:
参数定义 → 外部 JSON Schema(精确的,从 OpenAPI 提取的)
参数执行 → C# 方法(可能是动态的、反射的、甚至远程调用的)
框架假设的场景:
C# 方法签名 → 框架自动推断 Schema → 两者绑死在一起框架把"参数定义"和"参数执行"绑死在同一个 C# 方法签名上。 但实际场景中,这两者是分离的——Schema 来自外部定义,执行逻辑来自代码。而且框架不接受你传入自定义 Schema,它只认自己推断的。
这就导致:你手里有正确的 Schema(从 OpenAPI 提取的),但框架不用你的,非要用自己从 C# 签名推断的。推断出来的 Schema 和外部定义不一致,LLM 按错误的 Schema 生成参数,你的代码解析不了。
解决方案:装饰器——只换 Schema,不换执行
既然框架不允许传入自定义 Schema,那就用装饰器在外面包一层:
csharp
// 1. 先用框架创建默认的 AIFunction(拿到执行能力,Schema 是自动推断的)
var defaultFunc = AIFunctionFactory.Create(myDelegate);
// 2. 用装饰器把 Schema 换掉(换成外部定义的精确 Schema)
var realFunc = new PluginAIFunction(defaultFunc, myPreciseSchema);PluginAIFunction 继承 DelegatingAIFunction——其他所有行为都委托给原始的 AIFunction,只覆盖一个属性:JsonSchema:
csharp
public sealed class PluginAIFunction : DelegatingAIFunction
{
private readonly JsonElement _jsonSchema; // 外部定义的精确 Schema
public PluginAIFunction(AIFunction innerFunction, JsonElement jsonSchema)
: base(innerFunction) // 执行能力全部委托给 innerFunction
{
_jsonSchema = jsonSchema;
}
public override JsonElement JsonSchema => _jsonSchema; // 只换这一个属性
}框架给了"执行"能力,但"描述"能力不够,所以用装饰器补上。 这不是微软故意设计得奇怪,而是他们的框架优先服务"工具就是 C# 方法"这个最常见的场景。对于工具来自外部定义的场景,只能通过装饰器绕过去。
踩坑总结:JSON Schema 是 Function Call 协议的命门。 你告诉 LLM 的 Schema 必须和框架实际接受的格式完全一致,否则 LLM 生成的参数你的代码解析不了。当框架自动推断的 Schema 不够精确时,用装饰器覆盖是唯一出路。
微软框架的封装:FunctionInvokingChatClient
手写这个循环太容易出错了。微软的 Microsoft.Extensions.AI 提供了一个装饰器——FunctionInvokingChatClient,它把这个循环封装成一个管道层:
IChatClient 管道:
FunctionInvokingChatClient ← 装饰器,自动处理工具调用循环
→ LoggingChatClient ← 日志装饰器
→ RetryingChatClient ← 重试装饰器
→ Leaf ChatClient ← 实际的 LLM(OpenAI / Ollama / ...)它内部做的事情:
| 职责 | 说明 |
|---|---|
| 自动循环 | for (int iteration = 0; ; iteration++),直到 LLM 不再返回工具调用 |
| 工具查找 | 按 function_call.name 从工具列表中匹配 AIFunction |
| 参数绑定 | AIFunctionFactory 自动把 JSON 参数绑定到函数签名 |
| 错误容错 | 工具连续失败 3 次后才抛异常,LLM 有机会换参数重试 |
| 死循环防护 | 最多 40 轮工具调用,防止 LLM 无限循环 |
| 并发调用 | LLM 一次返回多个 tool_calls 时可以并行执行 |
用伪代码表示它的核心逻辑:
GetResponseAsync(messages, options):
response = inner.GetResponseAsync(messages, options) // 第一次调 LLM
while true:
if response 包含 FunctionCallContent:
// LLM 要调工具 → 执行 → 结果喂回 → 再调 LLM
for each functionCall in response.FunctionCallContents:
tool = FindTool(functionCall.Name)
result = tool.Invoke(functionCall.Arguments)
messages.Add(ToolMessage(result))
response = inner.GetResponseAsync(messages, options) // 再调一次
continue
else:
// LLM 给出了最终回复 → 退出循环
break
return response这个循环看起来简单,但所有细节(错误处理、死循环防护、Schema 匹配、流式支持)都藏在框架内部。 你只需要把工具注册进去,框架自动处理剩下的。
其他框架的原理
其他框架也有类似的封装,原理差不多:
- LangGraph:循环是图结构的一部分——
agent 节点 → 条件边(还要调工具吗?)→ tools 节点 → 回到 agent,循环过程在图上可视化 - Hermes:手写 while 循环,更灵活但更容易出错
- AgentScope:在
ReActAgent内部用递归实现——reasoning(调LLM) → acting(执行tools) → 递归回 reasoning,对外完全透明
核心思路都一样:LLM 返回工具调用 → 执行 → 结果喂回 → 再调 LLM → 直到 LLM 给出最终回复。 区别只在于封装程度和扩展点。
业界怎么做的
同样的"Tool Call 循环"问题,其他框架是怎么封装的?
LangGraph 的做法是"条件边"——工具调用循环不是显式的 while,而是图结构的一部分。LangChain 底层只提供工具定义和 LLM 调用,不管循环。循环由 LangGraph 的图结构实现:
START → agent 节点(调 LLM)
↓
条件边:should_continue
├── 有 tool_calls → tools 节点(执行工具)→ 回到 agent 节点
└── 没有 tool_calls → ENDToolNode(工具执行节点)和 tools_condition(条件路由函数)都在 langgraph.prebuilt 包里。好处是循环过程可视化,每一步都能在图上看到;坏处是灵活性受限于图结构。
Hermes 的做法是手写 while 循环——没有框架封装,开发者自己写 while True + 检查 finish_reason + 执行工具 + 结果喂回。代码量约 50 行,灵活但容易出错——参数解析、错误处理、死循环防护都要自己写。
AgentScope Java 的做法最有意思——它在 ReActAgent 内部用递归实现循环,遵循 ReAct 模式(Reasoning + Acting):
agent.call(msgs) // 对外只有一个入口
→ executeIteration(0)
→ reasoning(0) // 调 LLM
→ isFinished()? // 检查返回里有没有 tool_calls
├── 没有 → 返回最终结果
└── 有 → acting(0) // 执行所有 tools
→ executeIteration(1) // 递归!回到 reasoning
→ reasoning(1)
→ ...循环直到 isFinished() 或 iter >= maxIters对外只暴露 agent.call() 一个接口,内部的 reasoning → acting → 递归 完全封装。Toolkit + ToolExecutor 负责单次工具执行(支持并行),但不负责循环控制——循环控制是 ReActAgent 的职责。
| 框架 | Tool Call 循环封装 | 循环实现方式 | 死循环防护 | 扩展点 |
|---|---|---|---|---|
| 微软 MEAI | FunctionInvokingChatClient 装饰器 | for + while 循环 | 最多 40 轮 | 管道装饰器 |
| LangGraph | 条件边 + ToolNode | 图结构循环 | 最大迭代次数 | 图结构(可自定义节点/边) |
| Hermes | 手写 while 循环 | 显式 while | 自己处理 | 完全自由 |
| AgentScope | ReActAgent 内部递归 | reasoning ↔ acting 递归 | maxIters 参数 | Agent 内部钩子 |
核心思路都一样:LLM 返回工具调用 → 执行 → 结果喂回 → 再调 LLM → 直到 LLM 给出最终回复。 区别只在于封装程度和扩展点。
四个框架的评价:哪个适合企业级
微软 MEAI(装饰器模式)——企业级首选。 侵入性最低,你不需要改代码结构,只需要在管道上加一层装饰器。装饰器可以叠加——日志、重试、限流、工具调用,各管各的,和 ASP.NET Core 中间件模式一脉相承。如果你的技术栈是 .NET,这是首选。
LangGraph(图结构)——适合复杂工作流,简单场景杀鸡用牛刀。 循环过程可视化是它的优势,但一个简单的 while 循环能解决的事,你要建图、建节点、建条件边、编译图、运行图。学习曲线陡,调试链路长。适合多步骤、多分支的复杂 Agent 编排,不适合简单的 Tool Call。
Hermes(手写 while)——只适合学习原理,不推荐生产用。 代码完全透明是优点,但每个开发者都要重新写一遍——参数解析、错误处理、死循环防护、流式支持,每个都容易出错。看着简单,真正要做到生产级,代码量远超 50 行。
AgentScope(递归 ReAct)——Java 平台的首选,也是目前 Java 开源领域最完整的选择。 Java 生态里 Agent 框架非常少,AgentScope 是唯一一个把 ReAct 模式做成完整架构的开源项目。对外只暴露 agent.call() 一个接口,内部 reasoning → acting → 递归全自动,钩子系统完善(每一步都能拦截)。缺点是太重——递归调用链 + 响应式编程(Mono/Flux),理解成本高,对普通 Java 开发者不友好。适合做平台级 Agent 系统,不适合轻量场景。
| 维度 | 推荐 | 说明 |
|---|---|---|
| 企业级首选 | 微软 MEAI | 装饰器模式,侵入性最低,组合性最强 |
| 复杂工作流 | LangGraph | 图编排适合多步骤、多分支场景 |
| Java 平台 | AgentScope | Java 开源 Agent 框架极少,这是最完整的选择 |
| 学习理解 | Hermes | 手写 while,原理最透明 |
| 不推荐生产 | Hermes | 灵活但容易出错,没有统一标准 |
| 杀鸡用牛刀 | LangGraph | 简单 Tool Call 场景不需要图编排 |
关键认知
Tool 是 Agent 的"手"——让 Agent 从"能说话"变成"能做事"。
但 Tool 不是孤立存在的。一个工具定义好了,还需要:
- 框架自动处理调用循环(FunctionInvokingChatClient)
- 正确的 Schema 让 LLM 能生成正确的参数(DelegatingAIFunction 包装)
- 不同类型的 Agent 看到不同的工具集(Provider 可覆盖模式)
工具解决了"能做事"的问题。但一个 Agent 不只是"能做事"——它还需要知识、需要规矩、需要分工。
这就是下一节要讨论的:Agent 到底是什么。