从生产级Agent理解Function calling:推导 PatchToolCalls (悬空工具调用修复)

1. 寻找“第一性原理”:大模型对历史对话格式的洁癖

抛开 patchtoolcalls 包,这个中间件解决的最核心、最原初的业务问题是什么?
答案是:解决多模型协作或用户强行打断时,对话历史(Message History)格式不合法导致大模型 API 报错的问题。

在 OpenAI 提出 Function Calling 标准后,所有支持工具调用的模型(如 GPT-4, Claude)都有一个极其严格的格式洁癖(Schema Validation)
如果历史对话中,Assistant 发起了一个工具调用(ToolCall),那么在它之后的对话中,必须紧跟着一个 Role: Tool 的消息,用来回答这个工具的执行结果。

如果不满足这个条件(即存在“悬空”的 ToolCall),当你把这段历史记录发给大模型时,大模型 API 会直接报错 HTTP 400:"Invalid request: missing tool response for tool_call_id xxx".

补充:Function Calling 的严格对话流(Schema Validation)

为了理解大模型为什么会报错,我们需要看一下 OpenAI 定义的 Function Calling 标准历史记录序列。
一个合法的工具调用,必须是一个由两条消息组成的“闭环”

[
  // 1. 模型发起调用:我(Assistant)决定使用搜索工具
  {
    "role": "assistant",
    "content": null,
    "tool_calls": [
      {
        "id": "call_abc123",
        "type": "function",
        "function": { "name": "search_web", "arguments": "{\"query\": \"天气\"}" }
      }
    ]
  },
  // 2. 必须紧跟结果:这就是(Tool)调用 call_abc123 产生的结果
  {
    "role": "tool",
    "tool_call_id": "call_abc123",
    "content": "北京今天晴天"
  }
]

如果历史记录里只有第一条(模型发起调用),没有第二条(返回结果),就叫**“悬空(Dangling)”**。模型 API 在收到这段历史时,状态机无法闭环,就会拒绝继续生成。

延伸:Function Calling 的更多隐式约束

不仅是“悬空”会导致报错,在多智能体(Multi-Agent)和复杂编排中,Function Calling 还有几个极其容易踩坑的隐式约束:

  1. 并行调用必须全量返回
    如果 tool_calls 数组里同时发起了 3 个工具调用([id1, id2, id3]),那么在紧接着的后续消息中,必须连续出现 3 条 role: tool 的消息,而且这 3 条消息的 tool_call_id 必须和上面的一一对应(顺序可以不同,但一个都不能少)。如果漏掉任何一个,也会报 missing tool response

  2. 不能被 User Message 强行插队
    在一个合法的 [Assistant(Call) -> Tool(Response)] 闭环中间,绝对不能插入 role: user 的消息。
    错误示例:Assistant(Call) -> User(等等) -> Tool(结果)
    一旦插入,大模型的状态机就会认为闭环被破坏了。这也是为什么面对用户的突然打断,我们必须用 patchtoolcalls 把当前的调用先强行结束掉,然后再接受用户的下一句话。

  3. 连续的 ToolMessage 必须属于同一个 Assistant 意图
    如果你有两条连续的 role: tool 消息,它们必须是为了回应离它们最近的那一次 role: assistant 中发起的调用。你不能拿去回应很久以前发起的旧调用。

延伸 2:Message Role 的体系与命名强制性

在理解了 Function Calling 的格式后,我们需要退一步看看整个 Message 的角色体系。为什么上面的 JSON 里必须写 assistanttool?可以自己随便起名字吗?

答案是:绝对不行。Role 是大模型底层协议的关键字,属于强 Schema 约束。

在 Eino 的 schema/message.go ( message.go#L95-L108 ) 中,定义了四种唯一的 RoleType。这些 Role 直接映射了目前主流大模型(特别是遵循 OpenAI 标准的模型)的微调(Fine-tuning)范式:

  1. System (系统提示)

    • 作用:设定大模型的人设、背景知识、核心规则(比如“你是一个精通 Golang 的程序员”)。
    • 位置约束:通常必须放在历史消息数组的最开头(Index 0)。很多模型的 API 如果在中间突然插一个 System Message 会直接报错,或者权重被极度削弱。
  2. User (人类输入)

    • 作用:代表外部世界(通常是人类)的指令和问题。
    • 特点:它是驱动模型思考的触发器。如果一段历史记录只有 Assistant 和 Tool,模型会不知道自己要回答谁的问题。
  3. Assistant (模型输出)

    • 作用:代表大模型自己的发言或动作(包含思考过程、最终回答、以及发起工具调用 ToolCalls)。
    • 关键约束:在历史记录中,任何 Assistant 消息都会被模型认为是**“我自己以前说过的话”**。这就是为什么在多 Agent 协作时,如果直接把 Coder 的发言丢给 Reviewer,Reviewer 会产生身份错乱(以为那是自己写的代码)。必须用 History Rewrite 把 Coder 的话改写成 User 角色,或者利用某些支持 Name 字段的模型(见下文)来区分。
  4. Tool (工具返回)

    • 作用:专门用于承载工具执行的结果。
    • 关键约束:它不能单独存在,必须紧跟在携带了 ToolCallsAssistant 消息之后,并且必须携带与之对应的 ToolCallID

可以自己造 Role 吗?
不行。如果你造了一个 role: "reviewer",大模型的 API 会直接拒绝解析。
但是,为了解决多实体参与对话的问题(比如群聊中有 A、B、C 几个人),OpenAI 协议提供了一个额外的字段:Name
schema.Message ( message.go#L652 ) 中有一个 Name string 字段。
你可以构造这样的消息:{Role: "user", Name: "Alice", Content: "你好"}
这表示“这是属于人类角色的话,但具体是叫 Alice 的人类说的”。这才是大模型时代合法的身份区分方式。


2. 第一次演进:应对“中断与人工干预”的危机(Dangling ToolCall)

痛点与危机
在单次正常的 Agent 循环中,框架会自动执行工具并追加结果,不会出现悬空。但在复杂的 Agent 应用中,悬空(Dangling)是极其常见的:

  1. 人类强行打断(Human Interruption)
    大模型说:“我正在调用 delete_database 工具…”。
    此时用户眼疾手快,点击了“停止”按钮,并输入了一句新的话:“等一下!别删!”。
    此时的历史记录变成了:[Assistant(Call:delete), User("等一下!")]
    下一次把这段历史发给模型时,API 直接崩溃,因为 delete_database 的调用没有结果。

  2. 并行工具失败(Partial Failure)
    大模型一次性并行调用了 3 个工具 [A, B, C]。工具 A 和 B 执行成功并追加了结果,但工具 C 的执行引擎崩溃了,或者超时了。
    历史记录里只有 A 和 B 的结果。下次请求同样会崩溃。

引入第一次抽象:在发给模型前“打补丁” (Patching)

为了解决这个问题,我们需要在每次调用大模型之前,像“垃圾回收器”一样,扫描一遍历史记录。发现哪个 ToolCall 没有填坑,我们就伪造一个“空坑”填进去。

// 第一次演进:发请求前的历史记录修复
func RunAgent(history []Message) {
    // 每次调用大模型前,先扫描历史记录
    patchedHistory := patchDanglingToolCalls(history)
    
    // 用修复后的合法记录去请求大模型
    resp := llm.Generate(patchedHistory)
}

func patchDanglingToolCalls(history []Message) []Message {
    var newHistory []Message
    
    for i, msg := range history {
        newHistory = append(newHistory, msg)
        
        // 如果发现 Assistant 发起了工具调用
        if msg.Role == "assistant" && len(msg.ToolCalls) > 0 {
            for _, tc := range msg.ToolCalls {
                // 往后找,看看有没有人回答它
                if !hasResponse(history[i+1:], tc.ID) {
                    // 如果没人回答,强行伪造一个 ToolMessage 塞进去!
                    fakeMsg := Message{
                        Role: "tool", 
                        ToolCallID: tc.ID, 
                        Content: "被用户取消了,或者执行失败了",
                    }
                    newHistory = append(newHistory, fakeMsg)
                }
            }
        }
    }
    return newHistory
}

3. 映射到真实源码:Eino 的 patchtoolcalls 到底做了什么?

当我们审视 Eino 的 adk/middlewares/patchtoolcalls.go 时,会发现它的实现与我们的推导如出一辙,极其精简且目的明确。

  1. 切面注入点:BeforeModelRewriteState
    它利用了 Eino 的 ChatModelAgentMiddleware 机制中的 BeforeModelRewriteState 钩子。这个钩子在 Agent 准备好所有的 State(历史记录)后,真正发送给底层大模型(Model)的前一刻触发。这是进行“格式修复”的完美时机。

  2. 核心算法:扫描与补洞
    patchtoolcalls.go#L71-L91for 循环中,它做的事情就是我们推导的:

    • 找到所有的 schema.Assistant 且带 ToolCalls 的消息。
    • hasCorrespondingToolMessage 往后扫描。
    • 发现缺失,就调用 createPatchedToolMessage 伪造一条消息插入。
  3. 优雅的退路:PatchedContentGenerator
    框架默认填入的假消息是:“工具调用 xxx 被取消了——在其完成之前收到了另一条消息。”
    但为了给业务方留后路,Config 允许传入自定义的 PatchedContentGenerator。业务方可以根据不同的工具名字,生成更有针对性的“假结果”(比如对于搜索工具,可以返回“搜索超时,请换个词重试”),从而引导大模型进行更好的重试。


4. 批判性总结 (Critical Trade-offs)

优势:防御性编程的典范
这短短 100 行代码,是极其经典的防御性编程(Defensive Programming)。它不解决任何业务逻辑,纯粹是为了**“讨好大模型 API 的格式校验器”**。
有了它,外层的业务逻辑可以肆无忌惮地中断 Agent、删除部分消息、或者跳过某些耗时工具的执行,而不用担心系统崩溃。它极大地增强了 Agent 在复杂交互场景(如 Human-in-the-loop)下的鲁棒性。

代价与局限:O(N^2) 的扫描与“记忆污染”

  1. 性能隐患hasCorrespondingToolMessage 是在 for 循环里往后切片遍历的。在极端情况下(历史记录极长,且有大量 ToolCall),这是一个 O ( N 2 ) O(N^2) O(N2) 的操作。虽然在大部分文本对话场景下这点开销可以忽略不计,但如果是在高频自动执行的系统中,可以优化为用 map[string]bool 记录所有存在的 ToolCallID 从而实现 O ( N ) O(N) O(N) 扫描。
  2. 记忆污染(Memory Pollution):我们强行塞入的“假消息(比如:被取消了)”会永远留在历史记录里,成为大模型上下文的一部分。如果大模型对这个取消原因产生过度反应,可能会影响后续的推理。

更优解的探讨
其实在某些底层模型 SDK(比如 LangChain 的某些 Python 实现)中,遇到悬空的 ToolCall,它们的做法不是“填坑”,而是**“把悬空的 ToolCall 从历史记录里直接删掉”**。

  • 删掉(Drop):让大模型彻底失忆,以为自己没发起过这个调用。
  • 填坑(Patch):也就是 Eino 的做法,让大模型知道自己发起了,但是没成功。
    哪种更好?这取决于业务。如果是用户强行打断,填坑更好,因为大模型能意识到“人类制止了我”。如果是底层网络波动导致失败,删掉可能更好,让模型毫无心理负担地重新开始。Eino 目前只提供了“填坑”这一种策略。
Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐