Skip to content

第 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 循环: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"   // ← 关键信号:模型要调工具
  }]
}

注意两个关键点:

  • contentnull——模型没有要说的话,它要调工具
  • 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
DeepSeekFunction 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 → END

ToolNode(工具执行节点)和 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 循环封装循环实现方式死循环防护扩展点
微软 MEAIFunctionInvokingChatClient 装饰器for + while 循环最多 40 轮管道装饰器
LangGraph条件边 + ToolNode图结构循环最大迭代次数图结构(可自定义节点/边)
Hermes手写 while 循环显式 while自己处理完全自由
AgentScopeReActAgent 内部递归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 平台AgentScopeJava 开源 Agent 框架极少,这是最完整的选择
学习理解Hermes手写 while,原理最透明
不推荐生产Hermes灵活但容易出错,没有统一标准
杀鸡用牛刀LangGraph简单 Tool Call 场景不需要图编排

关键认知

Tool 是 Agent 的"手"——让 Agent 从"能说话"变成"能做事"。

但 Tool 不是孤立存在的。一个工具定义好了,还需要:

  • 框架自动处理调用循环(FunctionInvokingChatClient)
  • 正确的 Schema 让 LLM 能生成正确的参数(DelegatingAIFunction 包装)
  • 不同类型的 Agent 看到不同的工具集(Provider 可覆盖模式)

工具解决了"能做事"的问题。但一个 Agent 不只是"能做事"——它还需要知识、需要规矩、需要分工。

这就是下一节要讨论的:Agent 到底是什么。