- AI Agent
- 人工智能
- 大模型
- AI 应用
- 工具调用
- 本地部署
- MCP Clients
- Agent 记忆
【免费下载链接】Operit
The most powerful AI agent and AI chat software on Android/Operit是一款Android上能力最为强大、发展最久的AI Agent
导读
本文基于 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 的对话链路对工具结果采取了两层限制:
- 单个工具结果有长度限制(由
ToolExecutionLimits.MAX_SINGLE_TOOL_RESULT_MESSAGE_CHARS控制,值为MAX_FILE_READ_BYTES * 2 = 64,000,见 ToolExecutionLimits.kt); - 整轮工具结果又叠加了一层统一的 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,否则并行调用的后半段结果永远"有调用、无结果"。
作用域与改动边界
原文档将改动收敛为四个点:
- 删除整轮工具结果的字符上限——不再对
results.joinToString(...)拼接后的整条消息做统一截断; - 将限制常量明确为"单个工具结果上限"——语义从"整轮"收敛到"单条";
- 限制图片链接附加内容,避免单个结果再次突破上限——防止图片链接以追加方式绕过单条限制;
- 增加多工具结果保留的回归覆盖——用测试锁定"每条结果都被保留且各自限长"的行为。
对照源码,改动落在 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) )这段实现有三个值得注意的工程细节:
- 限额是"整条序列化结果"而非"纯 payload":先构造"空 content"的 XML 骨架,用
MAX_SINGLE_TOOL_RESULT_MESSAGE_CHARS减去骨架长度,得出 payload 可用的字符预算,保证最终序列化消息(含标签、属性)不会超过 64,000 字符; coerceAtLeast(0)兜底:即使骨架本身异常长(极端情况),预算也不会变成负数导致崩溃;- 截断时保留 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 的历史转换层:以 XML
tool_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 字符远超整轮上限,第二个结果必被丢弃;新逻辑下断言:
- 两个结果块都被保留(
assertEquals(2, resultBlocks.size)); - 每条独立限长(均不超过
MAX_SINGLE_TOOL_RESULT_MESSAGE_CHARS); - 两个工具的 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
相关推荐
如何理解fzf的高效结果合并:merger.go的核心策略解析
如何理解fzf的高效结果合并:merger.go的核心策略解析 fzf作为一款强大的命令行模糊查找工具,其高效的结果合并机制是实现快速搜索体验的关键。本文将深入
CLICrossfilter与D3.js集成指南:打造专业级数据可视化应用
Crossfilter与D3.js集成指南:打造专业级数据可视化应用 Crossfilter是一个强大的JavaScript库,专为在浏览器中探索大型多元数据集
Markdown编辑器的未来趋势:从Awesome Markdown Editors看技术演进
Markdown编辑器的未来趋势:从Awesome Markdown Editors看技术演进 Markdown作为一种轻量级标记语言,已成为内容创作、文档编写
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考