Function Calling 协议拆解

拆解一次 function calling 的 JSON Schema、模型调用意图、程序执行和结果回传,厘清多工具调用、stop reason 与协议差异。

outlier 16 分钟 6047 字 0 0
系列教程Agent从入门到放弃 第 20 / 32 章
  1. 01Agent 从入门到放弃:教程大纲
  2. 02大模型、Chatbot、Agent,一次把名字理清楚
  3. 03Agent 的最小定义,一个 while 循环
  4. 04Agent 简史,从 ReAct 到 Claude Code
  5. 05Workflow 还是 Agent?大部分场景你不需要 Agent
  6. 06Agent 能力地图,为什么编码 Agent 最先成熟
  7. 07Token,模型眼里的世界
  8. 08Next-token prediction,一个字一个字地猜
  9. 09采样,temperature、top-p 与"同题不同答"
  10. 10Transformer 与 Attention,不推公式的直觉版
  11. 11Context Window,模型的内存条
  12. 12训练三阶段,能力是怎么被"装"进去的
  13. 13推理模型,让模型先想再答
  14. 14用原理解释怪行为,原理篇结业考
  15. 15从 Prompt 工程到 Context 工程
  16. 16System Prompt 写作,角色、约束与示例
  17. 17Context Rot,为什么长对话越聊越笨
  18. 18项目记忆,CLAUDE.md 与 AGENTS.md
  19. 19检索,RAG 还是 Agentic Search
  20. 20Function Calling 协议拆解
  21. 21工具设计,写给模型用的 API
  22. 22MCP 入门,工具界的 USB-C
  23. 23用 Go 手写一个 MCP Server
  24. 24MCP 进阶与边界
  25. 25手写 Agent ②,第一个工具,read_file 与 list_files
  26. 26手写 Agent ③,让它会改代码,edit_file
  27. 27手写 Agent ④,给它一个终端,run_bash 与权限
  28. 28手写 Agent ⑤,上下文管理
  29. 29手写 Agent ⑥,流式输出、错误处理与重试
  30. 30手写 Agent ⑧,Skills 与 MCP 接入
  31. 31Claude Code 解剖,对照自己的实现
  32. 32自动化,Hooks、自定义命令与 Headless 模式
查看教程目录

开场:模型说它查过天气了

我第一次自己实现工具调用的时候,写了一个查天气的工具,问模型"杭州今天适合跑步吗?"

模型很快回答:"我查了一下,杭州今天多云,21 度,空气质量良好,非常适合跑步!"

我很高兴,然后看了一眼日志:我的查天气函数一次都没有被调用过。

它没有查,它编的。原因是我的代码有个 bug:模型返回了一个"我想调用 get_weather"的请求,我的程序没有识别出来,直接把它当成普通回复处理了。而模型那边,因为我在 prompt 里描述了这个工具,它"觉得"自己应该已经查过了,于是在下一轮直接编了一个结果。

那天我盯着日志看了很久:一个从没被执行过的函数,产出了一个有温度、有天气、有空气质量的"结果"。这件事让我彻底记住了一个事实,也是这一篇的核心:模型只会输出"我想调用某个工具"的意图,它从来不会、也不能真正执行任何东西。 执行是你的程序的事。程序没执行,那个"查过了"就是假的。

从这一篇开始进入第四部分:协议篇。前面三部分,我们一直在说"模型调用工具",这一篇把这件事拆到每一个字段。读完本篇,你应该能回答这几个问题:

  • 工具是怎么用 JSON Schema 定义的?
  • 模型的"调用意图"和程序的"执行结果",在协议里分别长什么样?
  • stop_reason 是什么意思?
  • 一次回复里调用多个工具时,结果怎么交回去?
  • 不同厂商的协议有什么区别?
  • JSON Schema 能保证参数一定合法吗?流式输出时调用意图是怎么到达的?

一、全景:一次往返的三步

一次完整的工具调用,是三步:

sequenceDiagram participant P as 你的程序(harness) participant M as 模型 participant T as 工具(你的函数) P->>M: ① 问题 + 工具定义 M-->>P: 我想调用 get_weather(city=杭州)
stop_reason: tool_use Note over P: 模型停下来了,
在等你 P->>T: ② 执行 get_weather("杭州") T-->>P: {"temp_c": 21, "condition": "多云", "aqi": 38} P->>M: ③ 原问题 + 模型的调用 + 执行结果 M-->>P: 杭州今天多云,21 度……适合跑步
stop_reason: end_turn
  1. 带着工具定义提问:告诉模型有哪些工具可用;
  2. 程序执行工具:模型说它想调用什么,程序去执行;
  3. 把结果交还给模型:模型看到结果,给出最终回答。

第 02 篇的 Agent 循环,就是把这三步重复很多次,直到模型不再要求调用工具为止。这一篇我们只看一次往返,但看得非常细。

二、第一步:用 JSON Schema 定义工具

2.1 一个工具定义

配套代码 examples/ch19-function-calling/main.go 定义了一个查天气的工具:

var tools = []llm.Tool{{
    Name:        "get_weather",
    Description: "查询某个城市当前的天气。返回温度(摄氏度)、天气状况和空气质量指数。",
    InputSchema: json.RawMessage(`{
        "type": "object",
        "properties": {"city": {"type": "string", "description": "城市名,例如:杭州"}},
        "required": ["city"]
    }`),
}}

三样东西:

  • 名字:get_weather。模型想调用它时,会写出这个名字;
  • 描述:一句话说清楚它干什么、返回什么。模型选不选这个工具、怎么用它,几乎全看这段描述;
  • 参数的 JSON Schema:参数有哪些、什么类型、哪些是必填的、每个参数是什么意思。

模型能看到的,只有这三样。 它看不到你的函数实现,不知道你的函数会调用哪个天气 API,不知道它要花多长时间。工具的定义,就是模型对这个工具的全部认知。

2.2 JSON Schema 的几个常用写法

JSON Schema 是一套描述 JSON 数据结构的标准。工具定义里常用的就这么几个:

写法意思
"type": "object"参数整体是一个 JSON 对象
"properties": {...}对象里有哪些字段
"type": "string" / "integer" / "boolean" / "array"字段的类型
"description": "..."字段的说明,模型会读
"required": [...]哪些字段必须填
"enum": [...]只能从这几个值里选

注意每个字段的 description。"城市名,例如:杭州" 这句话,既告诉了模型这个参数是什么,也通过例子告诉了它格式:是"杭州",不是"Hangzhou",也不是"浙江省杭州市"。第 15 篇说"一个示例胜过十个形容词",在参数描述里同样成立。

2.3 它最终去了哪里

第 11 篇讲过,API 会把 messages 按对话模板拼成一段文本交给模型。工具定义也一样:服务端把它们拼进上下文里(通常是 system 部分附近)。所以:

  • 工具定义也占 token,每次请求都要带(第 14 篇);
  • 工具描述本质上就是 prompt,写法遵循第 15 篇的所有原则。第 20 篇会专门讲怎么写。

三、第二步:模型的"调用意图"

3.1 模型返回了什么

发出带工具定义的请求之后,模型如果觉得需要用工具,它的回复长这样(配套仓库内部的格式,.fc-trace/1-response.json):

{
  "role": "assistant",
  "content": [
    { "type": "text", "text": "我先查一下杭州的天气。" },
    {
      "type": "tool_use",
      "id": "toolu_1",
      "name": "get_weather",
      "input": { "city": "杭州" }
    }
  ],
  "stop_reason": "tool_use"
}

逐个字段看:

  • text 块:可选。模型在调用工具之前说的话,可以展示给用户看;
  • tool_use 块:调用意图。

    • name:要调用哪个工具,一定是你定义过的某个名字;
    • input:参数,一个 JSON 对象,结构符合你定义的 schema;
    • id:这次调用的唯一编号。记住它,第三步要用;
  • stop_reason: "tool_use":这是最关键的信号。它的意思是"我停下来了,不是因为说完了,而是因为在等工具的结果"。

3.2 模型是怎么"决定"调用哪个工具的

tool_use 块看起来像是模型做了一个"决策",然后填了一张表。但回想第 07 篇和第 11 篇,它在底层做的事情和生成普通文字没有区别:一个 token 一个 token 地往下写。

它先写到一个特殊的"要调用工具了"的标记,然后写工具的名字,get、_weather,再写参数的 JSON,一个字符一个字符地生成出来。每一步选哪个 token,依据的就是上下文里的一切:用户的问题、工具的名字和描述、参数的说明、之前的对话。

这个视角能解释几个常见的现象:

  • 工具名字起得像,就容易选错:两个工具叫 get_user 和 get_users,模型在写到 get_user 的时候,下一个 token 是不是 s,就全看它对上下文的理解了;
  • 参数描述里的例子很管用:"例如:杭州" 这几个字,直接影响了它写参数值时的 token 选择;
  • 它可能在不该调用的时候调用,或者该调用的时候不调用:要不要写出那个"调用工具"的标记,本身也是一次 token 选择。工具描述里写清楚"什么时候该用",就是在影响这一次选择。

第 20 篇讲工具设计时的很多原则,都是从这个视角推出来的。

3.3 stop_reason:模型为什么停下

第 02 篇列过 stop_reason 的几种取值,这里再强调一下它在协议里的地位:harness 完全靠它来决定下一步做什么。

flowchart LR R["模型的回复"] --> S{"stop_reason"} S -->|"tool_use"| T["去执行工具"] S -->|"end_turn"| D["把回答交给用户"] S -->|"max_tokens"| X["输出被截断了
(第 29 篇)"] style T fill:#fff3e0,stroke:#e65100 style D fill:#e8f5e9,stroke:#2e7d32

开场那个 bug,就是我的代码没有检查 stop_reason,把一个 tool_use 的回复当成了 end_turn 来处理。

3.4 核心认知:意图,不是执行

再看一遍那个 tool_use 块。它只是一段 JSON,意思是"我想用 杭州 这个参数调用 get_weather"。就这么多。 模型没有发起任何网络请求,没有调用任何函数,什么都没有"发生"。

这件事有几个推论,后面会反复用到:

  • 执行权完全在你手里:你可以执行,可以拒绝,可以修改参数再执行,可以先问用户一句。第 27 篇的权限系统,就建立在这一点上;
  • 模型可能会提出你不想执行的调用:参数不合法、路径越界、命令很危险。harness 必须检查;
  • 工具的结果是你说了算的:你交回去什么,模型就信什么。第 02 篇的剧本模型、本篇的假天气函数,都是这么"骗"它的。这也意味着,如果工具返回的内容里藏着恶意的指令,模型也会读到(第 38 篇)。

3.5 schema 到底保证了什么

一个很自然的问题:既然工具定义里写了 JSON Schema,那模型返回的 input 是不是一定符合 schema?

不一定。 这取决于服务端用的是哪种机制:

  • 只当说明书:schema 被拼进上下文,模型"读"了它,然后按自己的理解去生成参数。大部分时候都是对的,因为模型在训练中见过大量这样的格式;但它终究是在一个 token 一个 token 地生成(第 07 篇),偶尔会漏掉一个必填字段、把整数写成字符串、或者在 enum 之外编一个值;
  • 约束解码:服务端在模型生成参数的时候,实时检查每一个 token 是否符合 schema,不符合的 token 直接把概率设为零,根本不让它被选中(回想第 08 篇,采样就是从概率分布里抽签,把一些候选的概率清零,它们就永远抽不中)。这样生成出来的参数,结构上一定是合法的。很多厂商把这个功能叫做 strict 模式,或者 structured outputs。
flowchart LR S["JSON Schema"] --> A["只当说明书
模型照着写
大概率对"] S --> B["约束解码
不合法的 token 不让选
结构一定对"] style A fill:#fff9c4 style B fill:#e8f5e9,stroke:#2e7d32

注意,约束解码保证的是结构,不是内容。它能保证 city 是一个字符串,但保证不了这个字符串是"杭州"而不是一整句问题;能保证 path 字段存在,但保证不了路径没有越界。所以不管有没有 strict 模式,下面坑 4 那句话都成立:模型给的参数,要像用户输入一样校验。

5.4 节会说到,DeepSeek 的 strict 模式截至写作时还是测试功能,配套仓库没有用它,而是在工具那一侧自己校验参数。

3.6 tool_choice:要不要让它调用

请求里还有一个控制工具调用的参数,叫 tool_choice。常见的几个取值(各家名字略有不同):

取值意思用在什么时候
auto模型自己决定调不调、调哪个默认,Agent 几乎总是用它
none这一次不许调用工具想让它基于已有的结果直接总结
required / any必须调用某个工具想强制它先查再答
指定某个工具必须调用这一个把工具当成"结构化输出"来用

最后一种用法很巧妙:你定义一个"提交分类结果"的工具,参数就是你想要的那个 JSON 结构,然后强制模型调用它。模型"调用"的那一刻,你就拿到了一个按 schema 生成的 JSON,根本不用真的去执行什么。在没有专门的 structured outputs 功能的年代,这是让模型输出结构化数据最可靠的办法之一。

不过 DeepSeek 在思考模式下只支持 auto 和 none。想让它"必须调用",只能在 prompt 里引导,比如"回答之前,先用 get_weather 查询天气"。

四、第三步:把结果交还给模型

4.1 tool_result 长什么样

程序执行完工具,要把结果交回给模型。第二次请求的 messages 是这样的(.fc-trace/2-request.json):

[
  {
    "role": "user",
    "content": [{ "type": "text", "text": "杭州今天天气怎么样?适合跑步吗?" }]
  },
  {
    "role": "assistant",
    "content": [
      { "type": "text", "text": "我先查一下杭州的天气。" },
      { "type": "tool_use", "id": "toolu_1", "name": "get_weather", "input": { "city": "杭州" } }
    ]
  },
  {
    "role": "user",
    "content": [
      {
        "type": "tool_result",
        "tool_use_id": "toolu_1",
        "content": "{\"temp_c\": 21, \"condition\": \"多云\", \"aqi\": 38}"
      }
    ]
  }
]

三条消息:

  1. 原来的问题;
  2. 模型上一轮的回复,原样带回去,包括那个 tool_use 块;
  3. 一条新的 user 消息,里面是 tool_result 块:

    • tool_use_id:和第 2 条消息里那个 id 对上号,告诉模型"这是那次调用的结果";
    • content:执行结果,一段文本;
    • 还可以有 is_error: true,表示"执行出错了",content 里放错误信息。

模型看到这三条消息,就知道:我问了天气,我决定调用工具,工具返回了这个结果。然后它基于结果,给出最终的回答,stop_reason 是 end_turn。

4.2 两个规定,违反就 400

第 02 篇说过的两条铁律,在这里具体化了:

  1. 模型上一轮的回复要完整地带回去。不能只带 tool_result,不带对应的 tool_use。模型(和 API)需要看到"我调用了什么",才能理解"这是什么的结果";
  2. 每个 tool_use 都必须有对应的 tool_result,用 id 配对。

4.3 并行工具调用

模型可以在一次回复里调用多个工具。比如问"北京、上海、深圳今天天气怎么样?",它可能一次返回三个 tool_use 块:

"content": [
  { "type": "tool_use", "id": "toolu_1", "name": "get_weather", "input": { "city": "北京" } },
  { "type": "tool_use", "id": "toolu_2", "name": "get_weather", "input": { "city": "上海" } },
  { "type": "tool_use", "id": "toolu_3", "name": "get_weather", "input": { "city": "深圳" } }
]

程序可以并发地执行这三个调用(第 30 篇会在 mini-agent 里实现),然后把三个结果放进同一条 user 消息里交回去:

{
  "role": "user",
  "content": [
    { "type": "tool_result", "tool_use_id": "toolu_1", "content": "……北京的天气……" },
    { "type": "tool_result", "tool_use_id": "toolu_2", "content": "……上海的天气……" },
    { "type": "tool_result", "tool_use_id": "toolu_3", "content": "……深圳的天气……" }
  ]
}

一定是同一条消息。 拆成三条 user 消息,就违反了"user 和 assistant 严格交替"的规定。

这也回答了一个问题:为什么要有 id 这个字段? 只有一个调用的时候,按顺序对应似乎就够了。但有了并行调用之后,三个调用可能同时开始、以任意顺序完成:深圳的结果可能最先回来,北京的最后回来。harness 用 id 把每个结果挂回它对应的那次调用,模型也靠 id 知道哪个结果是哪个城市的。顺序可以乱,id 不能错。

五、同一个意思,另一套字段名

5.1 OpenAI 兼容格式

上面用的是配套仓库内部的"内容块"格式。不同厂商的协议,意思一样,字段名不一样。DeepSeek 用的是 OpenAI 兼容的格式,配套代码会把同一个请求翻译成 DeepSeek 的格式,存成 .fc-trace/2-request.deepseek.json:

{
  "model": "deepseek-flash",
  "messages": [
    { "role": "user", "content": "杭州今天天气怎么样?适合跑步吗?" },
    {
      "role": "assistant",
      "content": "我先查一下杭州的天气。",
      "tool_calls": [
        {
          "id": "call_1",
          "type": "function",
          "function": { "name": "get_weather", "arguments": "{\"city\":\"杭州\"}" }
        }
      ]
    },
    {
      "role": "tool",
      "tool_call_id": "call_1",
      "content": "{\"temp_c\": 21, \"condition\": \"多云\", \"aqi\": 38}"
    }
  ],
  "tools": [
    {
      "type": "function",
      "function": {
        "name": "get_weather",
        "description": "查询某个城市当前的天气。返回温度(摄氏度)、天气状况和空气质量指数。",
        "parameters": { "type": "object", "properties": { "city": { "type": "string", "description": "城市名,例如:杭州" } }, "required": ["city"] }
      }
    }
  ]
}

(id 是 DeepSeek 生成的,实际运行时会是一串随机字符。)

5.2 对照表

概念内容块格式(仓库内部)OpenAI 兼容格式(DeepSeek)
工具定义{name, description, input_schema}{type: "function", function: {name, description, parameters}}
调用意图assistant 消息里的 tool_use 块assistant 消息的 tool_calls 字段
调用参数input:JSON 对象arguments:JSON 字符串
调用编号idid
执行结果user 消息里的 tool_result 块单独的 role: "tool" 消息
结果配对tool_use_idtool_call_id
多个结果放进同一条 user 消息每个结果一条 tool 消息,紧跟在 assistant 后面
结果出错is_error: true没有专门的字段,写进内容里
停止原因stop_reason: "tool_use"finish_reason: "tool_calls"

有两个差别值得特别注意:

  • arguments 是一个字符串,里面装着 JSON,而不是一个 JSON 对象。你需要再解析一次。而且,模型生成的这个字符串有可能不是合法的 JSON(比如被 max_tokens 截断了)。配套代码的处理是:解析失败就当成空对象,让工具自己报"缺少参数",模型看到错误会重试;
  • 多个结果的放法正好相反:一个要求放进同一条消息,一个要求每个结果单独一条。

翻译的代码在 internal/llm/deepseek_format.go 里,一共两百来行。看懂了这张对照表,接任何一家的模型,都只是换一套字段名。

5.3 流式输出时,调用意图是一块一块到的

还有一个细节,第 29 篇实现流式输出时会遇到:开了流式,tool_calls 不是一次到齐的。 服务端会先发一个块,告诉你"第 0 个工具调用,id 是 call_1,名字是 read_file,参数是空字符串";然后再一小段一小段地发参数,比如先发 {"path",再发 :"main.go"}。harness 要按每个调用的 index 把这些碎片拼起来,等流结束了,才能得到完整的参数。

配套仓库的 internal/llm/deepseek_stream.go 开头的注释里,就列着这样几行原始的流式数据。这也解释了为什么 arguments 要设计成字符串而不是对象:字符串可以被切成碎片一段段地发,一个 JSON 对象却很难"发一半"。

如果流在中途断了,拼出来的参数就是半截 JSON,解析会失败。这时候配套代码会返回一个"流意外结束"的错误,交给第 29 篇的重试逻辑去处理,而不是拿着半截参数去执行工具。

5.4 DeepSeek 的几个额外规定

截至写作时(以官方文档为准):

  • 思考模式下,之前每一轮的 reasoning_content 都要原样带回去(第 12 篇讲过),否则 400。配套的翻译代码会把 thinking 块放回 assistant 消息的 reasoning_content 字段;
  • tool_choice 只能是 auto 或 none:思考模式下,不能强制模型"必须调用某个工具"(3.6 节)。想让它调用,就在 prompt 里引导;
  • 工具的 strict 模式(让参数严格符合 schema)还是测试功能。

六、动手:手动走一次往返

实验 1:跑一遍,看每一步的 JSON

go run ./examples/ch19-function-calling

程序会走完三步,打印每一步发生了什么,并把六个 JSON 文件存进 .fc-trace/ 目录:

1-request.json            带 tools 定义的请求(仓库内部的内容块格式)
1-request.deepseek.json   同一个请求翻译成 DeepSeek 格式
1-response.json           stop_reason = "tool_use",content 里有 tool_use 块
2-request.json            原样带回 assistant 内容 + 一条装着 tool_result 的 user 消息
2-request.deepseek.json   DeepSeek 格式:assistant 消息带 tool_calls,结果是 role:"tool" 的消息
2-response.json           stop_reason = "end_turn",最终回答

不设 Key 时用的是离线演示模型(也可以在 Playground 的第 19 篇里跑)。演示模型不太会填参数,它会把整句问题塞进 city 字段,于是假天气函数收到的城市是"杭州今天天气怎么样?适合跑步吗?"。但协议的每一步都是真实的,你可以对着六个文件,把第四、五节的每一个字段都找一遍。

打开 1-request.json 和 1-request.deepseek.json,并排对比;再对比 2-request.json 和 2-request.deepseek.json。重点看:

  • 工具定义的结构有什么不同;
  • tool_use 的 input 和 tool_calls 的 arguments,一个是对象,一个是字符串;
  • tool_result 和 role: "tool" 分别在哪里。

实验 2:接上真实模型

这一步要真实的 Key,在线 Playground 演示不了。
export DEEPSEEK_API_KEY=sk-...
go run ./examples/ch19-function-calling
go run ./examples/ch19-function-calling "北京和上海今天天气怎么样?"

接上 DeepSeek 之后,city 参数会被正确地填成"杭州"。第二条命令问两个城市,看看它是一次调用两个工具(并行工具调用),还是先查一个、再查一个。如果是前者,打开 2-request.deepseek.json,数一数有几条 role: "tool" 的消息。

思考模式默认开着,所以你还会在 2-request.deepseek.json 的 assistant 消息里看到 reasoning_content 字段,那就是 5.3 节说的"思考要原样带回去"。

实验 3:用 curl 把请求原样重放一次

大纲里这一篇的动手作业是"只用 curl 手动完成一次工具调用往返"。有了 .deepseek.json 文件,这件事就很简单了。接上 DeepSeek 跑完实验 2 之后:

curl https://api.deepseek.com/chat/completions \
  -H "Authorization: Bearer $DEEPSEEK_API_KEY" \
  -H "Content-Type: application/json" \
  -d @.fc-trace/2-request.deepseek.json

你会拿到一个原始的 DeepSeek 响应,finish_reason 是 stop,message.content 里是最终的回答。没有 SDK,没有框架,就是一个 HTTP POST。 试着手动修改一下文件里 role: "tool" 那条消息的 content,比如把温度改成 39 度,再发一次,看看模型的回答会不会跟着变。它会的,因为你交回去什么,它就信什么。

实验 4:改一改工具描述

打开 examples/ch19-function-calling/main.go,把 city 参数描述里的"例如:杭州"删掉,或者改成"城市的英文名,例如:Hangzhou",接上真实模型再跑一次。看看 1-response.json 里的 input 变成了什么样。

再试一个更"坏"的改法:把工具的 Description 改成一句模糊的"获取信息"。模型还会不会调用它?调用的时候,还知道该传什么参数吗?你会直观地感受到 2.1 节那句话:工具的定义,就是模型对这个工具的全部认知。

七、踩坑记录:工具调用的六个坑

坑 1:不检查 stop_reason。
开场的故事。没有识别出 tool_use,就没有执行工具,模型下一轮可能会直接编一个结果。harness 的每一轮,第一件事就是看 stop_reason。

坑 2:只带回 tool_result,没带回 tool_use。
为了"省 token",把模型上一轮的回复简化了,只保留文字部分。结果 tool_result 找不到对应的 tool_use,API 报错。模型的回复要完整地追加进历史。

坑 3:id 对不上。
自己生成了一个 id,而不是用模型给的那个;或者并行调用时,把结果的顺序搞乱了,tool_use_id 对错了号。结果张冠李戴,模型以为北京的天气是上海的。

坑 4:直接信任参数。
arguments 可能不是合法的 JSON;即使是,字段也可能不全、类型也可能不对、值也可能越界(一个路径参数是 ../../etc/passwd)。模型给的参数,要像用户输入一样校验。

坑 5:工具出错了,直接抛异常中断循环。
文件不存在、命令超时,这些不是程序的 bug,是正常的"结果"。把错误信息包成 tool_result(标上 is_error)交回给模型,它通常能自己换个办法(第 20、29 篇)。

坑 6:混用两套格式的规矩。
用内容块格式的思路去拼 OpenAI 格式的请求:把多个工具结果塞进一条消息;或者反过来,把每个结果拆成单独的消息。换一家 API 之前,先对照一遍 5.2 节那张表。

一句话总结

Function calling 是一个三步的协议:带着 JSON Schema 定义的工具提问,模型返回 tool_use 并以 stop_reason: tool_use 停下,程序执行后用 tool_result 按 id 把结果交回去;模型只输出调用意图,从不真正执行,执行和结果都由你说了算。

flowchart LR D["工具定义
名字 + 描述 + schema"] --> M(("模型")) M -->|"tool_use
意图"| H["你的程序
执行"] H -->|"tool_result
按 id 配对"| M M -->|"end_turn"| A["回答"] style M fill:#e3f2fd,stroke:#1565c0 style H fill:#fff3e0,stroke:#e65100

评论

还没有评论,来说点什么吧。

发表评论

Ctrl + Enter 快速提交
Enter 搜索Esc 关闭