☰
Operit 工具结果按调用独立限制:多工具并行结果的保留策略与实现剖析
2026/9/29 5:23:32 网站建设 项目流程
  • AI Agent
  • 人工智能
  • 大模型
  • AI 应用
  • 工具调用
  • 本地部署
  • MCP Clients
  • Agent 记忆

【免费下载链接】Operit

The most powerful AI agent and AI chat software on Android/Operit是一款Android上能力最为强大、发展最久的AI Agent

项目地址:https://gitcode.com/gh_mirrors/op/Operit
点击查看免费下载

导读

本文基于 Operit 仓库中docs/TODO/tool-result-per-call-limit_20260909/index.md的改造记录,深入讲解一条影响 AI Agent 多工具并行调用质量的工程细节:把"整轮工具结果统一 64,000 字符上限"改为"单个 tool_result 独立限长、整轮全量保留"。文章将结合源码 ConversationMarkupManager.kt、ToolExecutionLimits.kt 与回归测试,讲清问题成因、改动边界、实现原理与验证方法,帮助读者理解在长上下文 Agent 系统中如何平衡"结果完整性"与"上下文长度"。

原本状况:整轮 64,000 字符的"先到先得"困局

改造之前,Operit 的对话链路对工具结果采取了两层限制:

  1. 单个工具结果有长度限制(由ToolExecutionLimits.MAX_SINGLE_TOOL_RESULT_MESSAGE_CHARS控制,值为MAX_FILE_READ_BYTES * 2 = 64,000,见 ToolExecutionLimits.kt);
  2. 整轮工具结果又叠加了一层统一的 64,000 字符上限。

问题出在第二层:当 Agent 在一轮中并行调用多个工具(例如同时调用read_file、http_request、web_search)时,所有工具结果会拼接到一条用户消息里回传给模型。在"整轮统一上限"的约束下,前面工具的大结果会先占满整轮预算,排在后面的工具结果被直接截断丢弃,历史转换层只能把它们标记为"缺失"(unmatched)。

从源码结构看,这种丢弃会进一步污染后续链路:多个 Provider 的历史转换器(如 ClaudeProvider.kt、OpenAIProvider.kt、DeepseekProvider.kt 等)都内置了"未匹配 tool_result"的兜底逻辑(tool_result_partial_batch、tool_result_without_structured_match),用于在结果与调用对不上时把散落的工具调用刷成独立历史条目。这虽然保证历史不崩溃,但代价是模型丢失了后续工具的实际输出——例如搜到了内容却因预算被占满而看不到结果。

修改意图:只限单个结果,整轮全量保留

本次改造的核心意图非常明确,见 index.md:

只限制单个tool_result的序列化长度,整轮保留所有工具结果,确保每个工具调用都有对应的结果槽位。整体上下文长度继续由 token 统计和总结流程处理。

也就是说,长度治理的责任边界被重新划分:

  • 字符级限制:只作用于单个工具结果,防止单条结果无限膨胀撑爆一次请求;
  • 上下文总量控制:不再由"本轮结果拼接"承担,而是交给已有的 token 统计与上下文总结(summarization)机制,在更宏观的尺度上管理整个对话窗口。

这套划分符合"结果槽位与调用一一对应"的原则:只要模型发出了 N 个 tool_use,就必须收到 N 个完整的 tool_result,否则并行调用的后半段结果永远"有调用、无结果"。

作用域与改动边界

原文档将改动收敛为四个点:

  1. 删除整轮工具结果的字符上限——不再对results.joinToString(...)拼接后的整条消息做统一截断;
  2. 将限制常量明确为"单个工具结果上限"——语义从"整轮"收敛到"单条";
  3. 限制图片链接附加内容,避免单个结果再次突破上限——防止图片链接以追加方式绕过单条限制;
  4. 增加多工具结果保留的回归覆盖——用测试锁定"每条结果都被保留且各自限长"的行为。

对照源码,改动落在 ConversationMarkupManager.kt 的buildToolResultMessage(results: List<ToolResult>): String:它逐条调用formatToolResultForMessage,再以换行拼接,整个批次不再有任何整体长度上限。方法注释也明确写道:"Each result is bounded independently ...; the batch itself is not bounded because every tool call must retain a corresponding result slot."

进度状态

原文档标记本任务为implementation-complete,四项子任务(移除整轮截断、保留单个截断与 XML 完整性、增加回归测试、依照仓库约束不执行构建/测试命令)均已完成。文档 frontmatter 中的status: implementation-complete表明这是已落地实现而非设计草案,下文源码与测试即为佐证。

实现原理:单个结果如何独立限长

常量定义与取值

核心常量定义于 ToolExecutionLimits.kt:

object ToolExecutionLimits { const val MAX_FILE_READ_BYTES = 32_000 const val DEFAULT_FILE_READ_PART_LINES = 200 const val MAX_TEXT_RESULT_LENGTH = 5_000 /** Maximum serialized size of one tool result sent back to the model. */ const val MAX_SINGLE_TOOL_RESULT_MESSAGE_CHARS = MAX_FILE_READ_BYTES * 2 }

关键点:MAX_SINGLE_TOOL_RESULT_MESSAGE_CHARS = 32_000 * 2 = 64_000,数值上与原整轮上限一致,但语义从"整轮共享预算"变为"每一条结果的独立预算"。同文件还提供了MAX_TEXT_RESULT_LENGTH = 5_000(文本型结果的常规截断阈值)与MAX_FILE_READ_BYTES = 32_000(文件读取字节上限)两个配套常量,共同构成工具执行的限额体系。

单条限长与 XML 完整性

formatToolResultForMessage负责把单条ToolResult序列化为 XML,其核心是createBoundedToolResultXml(ConversationMarkupManager.kt):

val emptyXml = createToolResultXml( toolName = toolName, status = status, content = bodyBuilder("") ) val maxPayloadChars = (ToolExecutionLimits.MAX_SINGLE_TOOL_RESULT_MESSAGE_CHARS - emptyXml.length) .coerceAtLeast(0) val boundedPayload = truncatePayload(rawPayload, maxPayloadChars) return createToolResultXml( toolName = toolName, status = status, content = bodyBuilder(boundedPayload) )

这段实现有三个值得注意的工程细节:

  1. 限额是"整条序列化结果"而非"纯 payload":先构造"空 content"的 XML 骨架,用MAX_SINGLE_TOOL_RESULT_MESSAGE_CHARS减去骨架长度,得出 payload 可用的字符预算,保证最终序列化消息(含标签、属性)不会超过 64,000 字符;
  2. coerceAtLeast(0)兜底:即使骨架本身异常长(极端情况),预算也不会变成负数导致崩溃;
  3. 截断时保留 XML 闭合结构:truncatePayload在截断后追加"\n[工具结果过长,已截断]"后缀(TOOL_RESULT_TRUNCATION_SUFFIX,见 ConversationMarkupManager.kt),且始终以完整闭合的</tag>结尾,确保模型拿到的每条结果都是结构完整的 XML,不会因为截断产生半截标签污染整条历史。

另外,XML 的标签名由ChatMarkupRegex.generateRandomToolResultTagName()随机生成(形如<tool_result_Xy9Z ...>),测试 ToolExecutionManagerMarkupTest.kt 中即有<tool_result_Xy9Z、<tool_result_Qw7K这类多标签并存、各自闭合的断言,确保随机标签名不影响解析。

图片链接的独立预算:防止"绕道上限"

工具结果中常携带多模态图片链接(如<link type="image" id="..."></link>)。为避免图片链接在文本截断后以追加方式再次撑爆单条上限,实现专门做了两步处理:

  • splitImageLinksForModel(ConversationMarkupManager.kt):先用MediaLinkParser从原始 payload 中剥离图片链接,文本部分替换为 "Image attached as multimodal input." 提示语;
  • appendImageLinksWithinResultLimit(ConversationMarkupManager.kt):图片链接逐条回填,每追加一条都先计算availableChars = MAX_SINGLE_TOOL_RESULT_MESSAGE_CHARS - toolResultXml.length - 1,超出剩余预算的行直接跳过(return@forEach),从而保证"文本 + 图片链接"的最终拼接仍落在单条 64,000 字符之内。

这条正是原文档作用域第 3 条"限制图片链接附加内容,避免单个结果再次突破上限"的落地实现。

调用链:工具结果如何汇入对话

从调用链看,buildToolResultMessage是工具执行完成后把结果回灌给模型的总出口:

  • EnhancedAIService.kt 中,processToolResults一类的入口以toolResultMessageOverride ?: ConversationMarkupManager.buildToolResultMessage(results)生成整轮结果消息;若拼接结果为空则直接跳过本次 AI 请求并记录告警日志,避免把空消息发给模型;
  • 结果消息随后进入各 Provider 的历史转换层:以 XMLtool_result形式交给 OpenAIProvider.kt(转为role: "tool"消息)、ClaudeProvider.kt(转为tool_resultcontent 块)、GeminiProvider.kt(转为FunctionResponse)等。因此"整轮全量保留"直接意味着每个 Provider 历史里都能找到与每个 tool_use 对应的 tool_result,从根本上减少tool_result_partial_batch/tool_result_without_structured_match这类兜底路径的触发频率(相关兜底逻辑见 StructuredToolCallBridge.kt)。

回归测试:锁定"每条结果都被保留"

本次改造新增的回归测试位于 ConversationMarkupManagerResultLimitTest.kt:

@Test fun `keeps every tool result while bounding each result independently`() { val results = listOf( ToolResult("first_tool", true, StringResultData("a".repeat(63_000))), ToolResult("second_tool", true, StringResultData("b".repeat(63_000))) ) val message = ConversationMarkupManager.buildToolResultMessage(results) val resultBlocks = ChatMarkupRegex.toolResultAnyPattern.findAll(message).toList() assertEquals(2, resultBlocks.size) assertTrue( resultBlocks.all { it.value.length <= ToolExecutionLimits.MAX_SINGLE_TOOL_RESULT_MESSAGE_CHARS } ) assertTrue(message.contains("name=\"first_tool\"")) assertTrue(message.contains("name=\"second_tool\"")) }

这条测试精准地复现了旧缺陷场景:两个工具各产生 63,000 字符的结果,旧逻辑下两条合计 126,000 字符远超整轮上限,第二个结果必被丢弃;新逻辑下断言:

  1. 两个结果块都被保留(assertEquals(2, resultBlocks.size));
  2. 每条独立限长(均不超过MAX_SINGLE_TOOL_RESULT_MESSAGE_CHARS);
  3. 两个工具的 name 属性都出现在消息中,证明槽位一一对应。

在原文档"增加回归测试"一项完成后,该测试即成为防止此问题回潮的行为契约。

设计取舍与边界说明

  • 为什么不在拼接层统一限制?从设计意图看,整轮统一上限必然造成"后发结果被牺牲",而并行调用中结果的价值与执行顺序无关——前面的大结果不应挤掉后面的小结果。把预算按"调用"而非"轮次"分配,是保证多工具语义完整的最小改动。
  • 上下文长度如何控制?原文档明确说明"整体上下文长度继续由 token 统计和总结流程处理"。换言之,字符级限额只管单条卫生,对话窗口的总长度由 token 计数与上下文总结/裁剪机制在更上层治理,两者各司其职。
  • 前提与限制:本改造针对的是"单轮并行多工具"的结果保留,不改变单个工具自身的输出截断(如 DebuggerFileSystemTools.kt 中MAX_FILE_READ_BYTES的文件读取截断、LinuxFileSystemTools.kt 的[File truncated...]提示等),也不改变各 Provider 对历史长度自身的限制策略。

总结

Operit 的"工具结果按调用独立限制"改造,是一次典型的限制粒度重构:把长度预算从"轮次共享"改为"单调用独立",配合图片链接的独立预算与多结果保留回归测试,确保并行多工具调用时每个tool_result都有对应槽位、结构完整、单条不越界。其核心代码集中在 ConversationMarkupManager.kt 与 ToolExecutionLimits.kt,验证契约在 ConversationMarkupManagerResultLimitTest.kt。对于同样面临"多工具结果互相挤占预算"问题的长上下文 Agent 工程,这一"单条限长 + 整轮保留 + 上层 token 治理"的分层策略具有直接参考价值。

  • AI Agent
  • 人工智能
  • 大模型
  • AI 应用
  • 工具调用
  • 本地部署
  • MCP Clients
  • Agent 记忆

【免费下载链接】Operit

The most powerful AI agent and AI chat software on Android/Operit是一款Android上能力最为强大、发展最久的AI Agent

项目地址:https://gitcode.com/gh_mirrors/op/Operit
点击查看免费下载
上一篇:探索极速内存数据库:MasterMemory
下一篇:Qwopus3.5-9B-Coder-GGUF三阶段课程学习策略:从基础到高级的渐进式训练

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询