# Peri Code 并发工具调用设计——框架该替 LLM 兜哪些底

> **[Peri Code](https://github.com/konghayao/peri)** — 用 Rust 写的开源 Coding Agent，兼容 Claude Code 生态。<https://github.com/KonghaYao/peri>

我们有一次在跑大范围迁移任务，Peri 同时调了三个工具——读两个文件、执行一个 bash 脚本。bash 跑了几秒，用户按了 Ctrl+C。两个读文件已经完成，bash 被中断。这时候 Agent state 里有一条 AI 消息，包含三个 ToolUse，但只有两个 ToolResult 写进去了，bash 那个没有。

下一轮 LLM 请求，Anthropic 直接 400——`tool_use id xxxxxx has no matching tool_result`。

这不是工具执行的问题，是框架在并发路径上没有保证状态完整性。LLM 拿到了一份破损的消息历史，连发出请求的机会都没有，更谈不上自己处理。Anthropic 对并发工具调用的消息结构有两条硬约束：每个 ToolUse 必须有配对的 ToolResult，多个 ToolResult 必须合并在同一条 user 消息里。违反任意一条，都是 400。

## 所有 ToolResult 收集完再统一写入

串行调用时这不是问题——调一个，等完成，写结果。并发之后出现了串行里没有的情况：三个工具同时跑，一个失败或被取消，另外两个已经完成了。如果框架在这个时间窗口里写入了不完整的状态，破损是永久的——LLM 不知道历史是不是完整的，也没有办法修复一条缺少 ToolResult 的 ToolUse。

Peri 用 deferred-error 模式处理这个问题：所有 ToolResult 收集完成之前，state 不做任何写入；收集完成后，AI 消息和全部 ToolResult 一次性写入，之后才处理错误。取消信号、工具执行失败、中间件报错，全部延迟到写入完成之后才触发。

## 多个 ToolResult 必须合并进同一条 user 消息

Anthropic 要求并发工具调用的结果必须放在同一条 user 消息里——AI 消息包含 ToolUse A 和 ToolUse B，紧跟着一条 user 消息，content 数组里同时包含 ToolResult A 和 ToolResult B。把两个 ToolResult 拆成两条独立的 user 消息，Anthropic 同样 400。

框架内部用 `add_message` 逐条写入 ToolResult，state 里每条 ToolResult 是独立的 `BaseMessage::Tool`。直接把这个结构序列化发给 Anthropic 就会报错。

Peri 的处理是在适配层解决这个问题，不改变内部存储方式。Anthropic 适配器在序列化消息历史时，遇到 `BaseMessage::Tool`，先检查当前结果数组的最后一条是不是 user 消息——是的话就把这个 ToolResult block 插入进去，而不是新建一条消息。连续的多条 ToolResult 就这样被合并到同一条 user 消息的 content 数组里。

内部存储保持简单，每条结果独立，便于追踪和调试；API 合规性在序列化边界处理，不污染核心逻辑。OpenAI 兼容接口没有这个约束，适配层直接逐条生成 `role: "tool"` 消息，两套格式在各自的适配器里独立处理。

## 一个工具失败，不丢其他工具的结果

最早的实现遇到工具报错直接 return，整个调用链中断。三个并发工具里只有一个出错，另外两个跑出来的结果也丢了，用户只看到一条报错，任务从头来过。

改成让所有工具都跑到终态——成功的收结果，失败的收错误信息，统一写进 ToolResult 交给 LLM。LLM 拿到完整的执行报告，看到哪个成功、哪个失败、失败原因，然后决定是重试失败的那个、绕过它，还是告知用户。

Ctrl+C 取消是同样的逻辑。按了取消，已经完成的工具结果不丢，被取消的工具生成一条「interrupted by user」的 ToolResult 写入 state。下次恢复任务时，LLM 能看到上一轮完成了什么、哪个被打断了，不需要从头再来。

框架保住所有信息，决策留给 LLM。

## 同一批工具合并成一次审批

HITL 审批模式下，Agent 一轮调 5 个工具，如果逐个弹窗，用户连续做 5 次决策。每次弹窗都是一次打断，用户失去对任务整体进展的感知。

Peri 把同一批工具的审批合并成一次弹窗，用户一次看完这轮所有操作，逐个批准或拒绝。被拒绝的工具生成拒绝 ToolResult，其他工具正常进入执行阶段。用户一次交互，框架处理所有分叉路径。

审批完成后执行阶段不再打扰用户——批量审批和并发执行是两个独立的阶段，执行阶段的 17 个中间件钩子全都不走审批路径，这是有意为之的约束。

## 连续失败 5 次，框架注入纠正消息

LLM 在某个错误上卡住时，会连续用同样的参数调同一个工具，得到同样的报错，再重试。每轮它都在「尝试解决问题」，但它每轮只看当前 context，不统计自己已经失败了多少次，也就很难主动跳出去。

Peri 在框架层跨会话追踪连续失败，用「工具名 + 错误文本」作为 key 计数，成功则清零。同一个错误超过 5 次，往 state 注入一条系统消息，告诉 LLM 停止重试、分析根因。这条消息出现在下一轮 context 里，生产中见过几次，注入之后模型通常能调整策略或主动告知用户卡在哪里。

这是框架主动介入的边界——LLM 感知不到自己在打转，框架来统计，超出阈值就干预。

## 修完之后加了一条测试

那次 Ctrl+C 之后，我们加了一条测试：并发执行中途取消，验证 state 里的 ToolUse 和 ToolResult 数量一致。测试跑通之后再也没有在这个路径上遇到 400。

但更重要的是改变了一个判断——Agent 框架不只是把工具调用结果传给 LLM，它必须保证传过去的东西是合法的。LLM 看到的是框架处理完之后的结果，框架这一层出了问题，LLM 根本看不到，也没有办法修复。这一层没做好，模型再强也没用。

---

项目地址：[github.com/konghayao/peri](https://github.com/konghayao/peri)
