工具协议与只读三件套:schema 设计、分片归并与结果截断
给 Agent 装上读代码的眼睛:写出读文件、找文件、搜内容三个只读工具,把工具 schema、流式工具调用分片的归并、以及超长结果怎么截断这三件事一次定清楚,之后十八天都靠它。
今日目标
- 能为一个工具写出模型真能用对的描述与 JSON Schema,并说清描述里该写什么不该写什么
- 能正确归并流式到达的工具调用分片,并解释为什么不能假设序号从零开始
- 能设计工具结果的截断策略,在不撑爆上下文的前提下保住关键信息
今天这一章是全课的转折点:Agent 第一次能自己决定下一步去看什么。读完回到页面顶部把三条目标勾掉。
小白版讲解
先给读权限:能翻代码,不能动代码
新人入职第三天,你会给他什么?大概是仓库的读权限:能翻代码、能搜函数、能看测试,但还不能提交。这一步几乎所有团队都这么做,理由很朴素——读操作可逆,写操作不可逆。他读错一个文件,代价是浪费五分钟;他改错一个文件,代价可能是一次线上事故。
Agent 完全一样,而且更需要这一步,因为「读」这件事对它来说是从零开始的。前两天它没有任何工具,你问它「这个仓库的测试为什么红」,它只能凭空猜——它看不到你的文件。今天给它三个工具之后,它会自己走这样一条路:先看有哪些文件,再搜一个可疑的函数名,再读那段实现,最后给结论。
这一天值得单独占一整天,因为工具层的接口今天就定死了,后面十八天所有工具(改文件、跑命令、连 MCP、装 Skills)都长成同一个形状。今天定错,后面每天都在还债。具体要定三件事:工具怎么描述给模型、模型的调用请求怎么接住、工具的结果怎么送回去。三件事各有一个坑,正好对应今天的三段。
messages 数组(每轮都要整个重发一遍)
上面这张图里,昨天我们只做通了「模型输出文字」那半边。今天补上另外半边:模型请求调工具、我们执行、把结果作为一条新消息追加进去、再发一次——直到它不再要工具。这个闭环就是 Agent 的全部,其余二十天都是在给这个闭环加约束。
工具协议三件事:名字、描述、参数 schema
一个工具送到模型面前时,只有三样东西是它能看见的:名字、一段自然语言描述、一份参数的 JSON Schema。剩下的实现细节它完全不知道。
这句话有个直接推论,很多人第一次写工具时都会搞反:描述是写给模型看的,不是写给维护者看的。 所以它该写「什么时候用这个工具、参数怎么填、有什么限制」,而不该写「这个函数内部用了流式读取」。
export const globTool: ToolDef = {
name: 'glob',
// 描述里的三件事:用它干什么、语法长什么样、有什么限制
description:
'按通配符列出工作目录里的文件,返回相对路径。' +
'支持 ** 跨目录、* 同层任意字符、? 单个字符,例如 **/*.js 或 test/**/*.test.js。' +
'自动跳过 node_modules 与 .git。',
parameters: {
type: 'object',
properties: {
pattern: { type: 'string', description: '通配符,例如 **/*.js;不填等于 **/*' },
limit: { type: 'integer', description: '最多返回多少条,默认 200' },
},
required: ['pattern'],
additionalProperties: false,
},
readOnly: true, // D5 的审批门会用这个字段区分「要不要先问一句」
async run(args: unknown, ctx: ToolContext): Promise<ToolResult> {
// 实现细节模型看不到,所以这里怎么写都不影响它调得对不对
},
}GLOB_TOOL = ToolDef(
name="glob",
# 描述里的三件事:用它干什么、语法长什么样、有什么限制
description=(
"按通配符列出工作目录里的文件,返回相对路径。"
"支持 ** 跨目录、* 同层任意字符、? 单个字符,例如 **/*.js 或 test/**/*.test.js。"
"自动跳过 node_modules 与 .git。"
),
parameters={
"type": "object",
"properties": {
"pattern": {"type": "string", "description": "通配符,例如 **/*.js;不填等于 **/*"},
"limit": {"type": "integer", "description": "最多返回多少条,默认 200"},
},
"required": ["pattern"],
"additionalProperties": False,
},
read_only=True, # D5 的审批门会用这个字段区分「要不要先问一句」
run=run_glob,
)四条经验,都是能直接抄的:
- 名字用动词加名词,短小、全小写、下划线分隔。 模型对
read_file的理解比对fileReader稳定得多,因为训练数据里工具名基本都长这样。 - 描述里给例子。 模型对通配符与正则语法的记忆没你想的可靠,一个
**/*.js的例子能省掉两轮试错。 - 每个参数都写 description,默认值写进去。 「默认 200」这四个字会让模型在不需要限量时干脆不传这个参数。
- 别把两件事塞进一个工具。 「读文件或者列目录,看 path 是文件还是目录」这种设计,模型会一半时间用错。宁可两个工具。
还有一个字段是给我们自己看的:readOnly。今天三个工具都是只读,所以它看着没用;第五天的审批门就靠它区分「直接放行」和「先问一句」。今天定下这个字段,比第五天回来给每个工具补一遍便宜得多。
流式工具调用的真实形状:拼起来才是合法 JSON
现在看模型的调用请求长什么样。这里是新手最容易被文档骗到的地方——文档给的示例是非流式的完整结构,而你在流式下拿到的是被切碎的样子。
一条带工具调用的流,去掉外层包装,实测大概是这样(2026 年 9 月 7 日实测,一次调用切成了六到十二片):
{"index":1,"id":"call_abc","function":{"name":"glob","arguments":""}}
{"index":1,"function":{"arguments":"{\"pat"}}
{"index":1,"function":{"arguments":"tern\":\"**/"}}
{"index":1,"function":{"arguments":"*.js\"}"}}四条规律,每一条都会咬人:
- id 与函数名只在第一片出现,后面的分片只带参数增量。所以归并时不能覆盖,只能「有值才写」。
- 参数是一段被切碎的 JSON 文本,拼起来才合法。所以昨天定的类型里,
ToolCall.args是字符串不是对象——解析这件事必须发生在拼完之后。 - 切分点不讲道理,能切在引号中间、也能切在转义符中间。任何「拿到一片就试着解析一次」的写法都会得到一串失败。
- 结束原因是一个专门的值(工具调用而不是正常停止)。但这个字段不能单信:实测有模型在报「正常停止」的同时仍然给了工具分片,只看它会把这一轮当成说完了。稳妥的判据是「结束原因是工具调用,或者归并出了至少一个调用」。
序号不从零开始:一个实测踩到的坑
归并的唯一依据是分片上的序号。于是有一个所有人都会先犯一次的错误:假设序号从 0 开始,然后用数组下标去存。
这不是理论风险。2026 年 9 月 7 日,我在同一家网关(https://api.n1n.ai/v1)上用同一个模型(claude-haiku-4-5-20251001)、同一段代码跑了两次并行工具调用,两次返回的两个调用序号分别是:
第一次运行:index = 1, index = 2
第二次运行:index = 0, index = 1同一天、同一家网关、同一个模型、同一段代码,两种起点。所以正确的结论不是「某家从 1 开始」,而是序号的起点不可假设——我们只是实测见过两种。它是一个不透明的标识符,你唯一能依赖的性质是「同一次调用的分片带同一个序号」。
按到达顺序 push 进数组更糟,因为并行调用的分片是交错到达的:可能先来第二个调用的头,再来第一个调用的头,然后两者的参数片交替出现。按顺序拼,你会把两个调用的参数缝成一个。
export class ToolCallAccumulator {
/** 用 Map 而不是数组:序号可能从任意数字开始,也可能不连续 */
private readonly byIndex = new Map<number, Partial>()
push(delta: { index: number; id?: string; name?: string; argsDelta?: string }): void {
const slot = this.byIndex.get(delta.index) ?? { index: delta.index, args: '' }
if (delta.id) slot.id = delta.id // 只在第一片出现,所以「有值才写」
if (delta.name) slot.name = delta.name
if (delta.argsDelta) slot.args += delta.argsDelta // 参数是累加,不是覆盖
this.byIndex.set(delta.index, slot)
}
/** 按序号升序取出:不是按到达顺序,因为并行调用的分片是交错来的 */
drain(): ToolCall[] {
return [...this.byIndex.values()]
.sort((a, b) => a.index - b.index)
.map((slot, position) => ({
id: slot.id ?? `call_${slot.index}_${position}`, // id 缺失也要给一个
name: slot.name ?? '',
args: slot.args,
}))
}
}from dataclasses import dataclass, field
@dataclass
class ToolCallAccumulator:
# 用 dict 而不是 list:序号可能从任意数字开始,也可能不连续
by_index: dict[int, dict[str, str]] = field(default_factory=dict)
def push(self, delta: ToolCallDelta) -> None:
slot = self.by_index.setdefault(delta.index, {"args": ""})
if delta.id:
slot["id"] = delta.id # 只在第一片出现,所以「有值才写」
if delta.name:
slot["name"] = delta.name
if delta.args_delta:
slot["args"] += delta.args_delta # 参数是累加,不是覆盖
def drain(self) -> list[ToolCall]:
# 按序号升序取出:不是按到达顺序,因为并行调用的分片是交错来的
return [
ToolCall(
id=slot.get("id") or f"call_{index}", # id 缺失也要给一个
name=slot.get("name", ""),
args=slot["args"],
)
for index, slot in sorted(self.by_index.items())
]参数解析失败是常态:回灌给模型,不要抛给用户
拼完了,解析。这里有一个心态上的转变,是 Coding Agent 与普通后端服务最不一样的地方:
参数解析失败不是异常,是一种正常的返回值。
普通服务收到非法参数,抛个 400 就完了——请求方是程序,它下次会改对。Agent 的请求方是模型,它是猜着写的:会漏必填参数、会把数字写成字符串、会编一个不存在的工具名。这些错误的正确处理是把错误原样告诉它,让它自己改——就像新人交上来一段跑不通的代码,你把报错贴给他,比你替他改更有效。
所以工具执行入口有一条硬规则:永远返回结果,永远不抛异常。 四类情况都要翻译成一条「失败的结果」:
| 情况 | 回灌给模型的内容 |
|---|---|
| 工具名不存在(模型编的) | 没有这个工具,以及可用工具的清单 |
| 参数不是合法 JSON | 说明解析失败,并附上它自己发的原文前一段 |
| 缺必填参数 | 具体缺哪几个 |
| 工具自己抛异常 | 异常消息(不是堆栈) |
回灌的文本要具体到可操作。「操作失败」是没用的;「没有名为 edit_file 的工具。可用的工具是:glob、grep、read_file」是有用的——模型看完就会换一条路。本实验里可以直接看到这个现象:让离线剧本编一个还不存在的编辑工具,终端上是这样的:
⚙ edit_file({"path":"src/calc.js","old":"a / b"})
✘ edit_file 回灌 45 字符 · 没有名为 edit_file 的工具。可用的工具是:glob、grep、read_file
原来今天还没有编辑工具,只有三个只读的。那我先把代码读明白。
⚙ read_file({"path":"src/calc.js"})
✔ read_file 回灌 248 字符 · src/calc.js(第 1–13 行,共 13 行)还有两条不变量,漏一条下一轮请求就不合法,而且大多数网关会直接报错:助手消息必须带上它请求的那些调用,每个调用必须有且只有一条对应的结果消息——哪怕这个调用失败了。第六天讲错误分类时会回到这里;今天先把「不抛异常」这条纪律立住。
只读三件套各自的边界
三个工具,各有一个「不限量就出事」的地方。
read_file:读多少。 默认只读四百行,而不是整个文件。整文件塞进上下文是最常见的浪费,而且大多数时候模型看开头就知道该往哪找。另外两个细节:输出带行号(模型要靠行号定位,明天的编辑工具也要靠它对话),单行超过五百字符要截断(压缩过的 JS 一行能有几十万字符)。读到二进制文件要明确说「这是二进制」,别把乱码回灌回去。
glob:怎么排除。 遍历时跳过 node_modules、.git、dist 这类目录,否则一次调用能返回上万个文件,而且全是噪音。结果限量两百条,超了要告诉模型「还有多少条没给你」——不然它会以为看到了全部,然后在一个错误的前提上继续推理。空结果也要说清「工作目录里一共有多少个文件」,帮它判断是真没有还是自己写错了。
grep:三个维度都要限。 命中条数(默认五十条)、单行长度(一百二十字符)、文件范围(可选的通配符参数,先缩范围再搜比搜完再筛便宜得多)。输出格式固定成「路径冒号行号冒号内容」,因为这是模型见过最多的形状,它能直接照着去调 read_file 看上下文。
三个工具还共用一条边界:所有路径都必须落在工作目录里面。 模型会拿到相对路径,也会尝试往上跳几级——不是因为它坏,是因为它在猜。判断依据只能是「解析成绝对路径之后还在不在里面」,在字符串里查两个点不可靠。比较时记得给根目录加一个分隔符,否则同前缀的兄弟目录会被误判成在里面。
本实验里问一句「帮我看看这个仓库,测试为什么红」,离线剧本会走完 glob、grep、read_file 三步,三次结果分别回灌 50、361、248 个字符——这三个数字可复现,因为沙盒仓库是固定生成的。
结果截断:从中间截,留头留尾,并告诉模型截掉了多少
最后一件事,也是最容易被忽略的一件:工具结果要回灌进消息数组,而消息数组每一轮都整体重发一次。
这句话的分量要算一下才有体感。一次 grep 命中两千行、大约六万个字符,你不但这一轮要为它付一次输入 token,接下来每一轮都要再付一次;转五圈就是五次。更糟的是它会挤掉真正重要的上下文——用户的需求、之前读到的关键代码。
所以要给单个工具结果定一个上限。本课统一定成八千个字符,三条口径:
export const MAX_RESULT_CHARS = 8000
const HEAD_RATIO = 0.6 // 头多一点:最相关的内容通常在前面
export function truncateResult(result: ToolResult): ToolResult {
const text = result.content
if (text.length <= MAX_RESULT_CHARS) return result
const head = Math.floor(MAX_RESULT_CHARS * HEAD_RATIO)
const removed = text.length - MAX_RESULT_CHARS
// 中间那行说明是给模型看的:它看不懂省略号,但看得懂「带 offset 再读一次」
const note = `\n…(中间已截断 ${removed} 字符,全文共 ${text.length} 字符;需要中间部分请带 offset 再读一次)…\n`
return {
...result,
content: text.slice(0, head) + note + text.slice(text.length - (MAX_RESULT_CHARS - head)),
// 截断这件事只给人看,不必再占模型的 token:meta 不回灌
meta: { ...result.meta, truncated: true, originalChars: text.length },
}
}MAX_RESULT_CHARS = 8000
HEAD_RATIO = 0.6 # 头多一点:最相关的内容通常在前面
def truncate_result(result: ToolResult) -> ToolResult:
text = result.content
if len(text) <= MAX_RESULT_CHARS:
return result
head = int(MAX_RESULT_CHARS * HEAD_RATIO)
removed = len(text) - MAX_RESULT_CHARS
# 中间那行说明是给模型看的:它看不懂省略号,但看得懂「带 offset 再读一次」
note = (
f"\n…(中间已截断 {removed} 字符,全文共 {len(text)} 字符;"
"需要中间部分请带 offset 再读一次)…\n"
)
return replace(
result,
content=text[:head] + note + text[-(MAX_RESULT_CHARS - head) :],
# 截断这件事只给人看,不必再占模型的 token:meta 不回灌
meta={**(result.meta or {}), "truncated": True, "originalChars": len(text)},
)三条口径解释一下。一,从中间截,不能只留头——尾部往往有结论性的信息:报错的最后一行、测试的汇总行、文件末尾的导出清单。只留头会把结论丢掉,这是最常见的错误做法。二,必须写清截掉了多少、下一步怎么拿中间那段,模型看不懂省略号但看得懂指令。三,截断这件事本身放在 meta 里,只给终端渲染看,不回灌给模型。
还有一条设计上的选择:截断放在工具执行入口,不是放在每个工具里。工具作者只管把内容做对,上下文预算由一处统一管——不然二十天后你有十五个工具,每个都有一份自己的截断逻辑。
本实验的自检里可以看到两个可复现的数字:一个两万字符的假结果被截到 8054 字符(八千的上限加上那行说明),注明截掉了 12000 字符;让模型自己去读一个三万八千多字符的生成文件,回灌回去的同样是 8054 字符。至于总的上下文预算该怎么在系统提示、历史、工具结果之间分配,那是第十二天的题目——今天只管一件事:单个结果不许无限大。
源码导读
动手实验
今天的实验第一次让循环真的转起来。starter 挖了三个练习点,分别对应本章三段最容易做错的地方:分片归并、非法参数回灌、结果截断。原样跑是七项里过两项。
- 定义工具注册表与统一的执行入口,让循环只认识清单与执行两个方法,不认识任何具体工具。
- 实现三个只读工具,各自带参数校验与限量;三者共用一处路径边界检查,确认往上跳目录会被挡下来。
- 按序号把工具调用分片归并成完整调用,用
INJECT=bad_args喂一段非法参数,确认它变成一条失败结果被回灌,而不是抛出堆栈。 - 给工具结果加截断:超过八千字符就从中间截,保留首尾并写清截掉了多少。
- 跑自检:
MOCK=1 SELFTEST=1 pnpm start应该打印7/7 通过,其中调用顺序、截断后的字符数都是可复现的数字。
验收看五条勾:自检 7/7 通过;问一句话能看到 glob、grep、read_file 三张工具卡片依次出现并给出结论;交错到达且序号从 1 起的分片能归并成两个调用;两万字符的结果被截到 8054 字符并注明截掉 12000 字符;INJECT=bad_args 时循环继续走完且退出码是 0。
面试题
今天三道题,全都是「实现过就答得出、没实现过答不出」的类型:
- 工具的描述与 JSON Schema 该怎么写,才能让模型少犯错?
- 流式返回的工具调用参数怎么归并?有哪些不能做的假设?
- 工具结果远超上下文预算时,你的截断策略是什么?怎么避免截掉关键信息?
完整的中英题干、分析过程与答题要点见本课面试题库的第三天。第二题是本课最有区分度的一道——没自己归并过的人答不出「不能假设什么」,而这正是面试官想听的那半句。
检查清单与明日预告
- 能说出模型看得见的三样东西,以及描述里该写什么不该写什么
- 能说清为什么参数是字符串而不是对象,以及解析该发生在什么时候
- 能解释序号的起点为什么不可假设,以及按到达顺序拼错在哪
- 能列出四类要被翻译成失败结果的情况,以及两条消息不变量
- 三个只读工具各自的限量维度都能说出来,路径边界的判据也能说清
- 自检跑出
7/7 通过,并且知道 8054 这个数字是怎么来的
明天是 D4《文件编辑与 shell 执行:精确替换、冲突检测与超时可杀的子进程》,读权限升级成写权限。风险会一下变一个量级,所以明天的重点不是「怎么把字写进文件」,而是三种失败模式:旧内容不匹配、文件被外部改动、命令卡死不返回。顺序是刻意的——今天先把「读」做扎实,模型才有能力在改之前先确认自己要改的东西真的长成那样;明天结束时,它会第一次真的把沙盒仓库里那个失败的测试修绿。
面试题库
工具的描述与 JSON Schema 该怎么写,才能让模型少犯错?How do you write a tool's description and JSON Schema so the model gets it right more often?
国内高频海外高频基础#tool-design#json-schema分析过程 · 先想清楚再作答
- 这题看着像送分题,但答「写清楚一点」就没分了。区分度在于你知不知道模型看得见什么——只有名字、描述、参数 schema 三样,实现细节它一无所知。所以这是一道接口设计题,不是文档写作题。
- 怎么拆:把「模型会怎么犯错」倒推成「描述里该写什么」。它会用错工具(描述没说清什么时候用它)、会写错语法(没给例子)、会传多余参数(没写默认值,也没关掉额外属性)、会一半时间用错同一个工具(这个工具承担了两件事)。四种错误各对应一条写法。
- 结论落成四条可执行的规则:名字用动词加名词、全小写下划线分隔;描述写「什么时候用、参数怎么填、有什么限制」,并给一个真实例子;每个参数都写说明并把默认值写进去;一个工具只做一件事,宁可拆成两个。
- 再补一条别人常漏的:schema 里要有给自己看的字段。比如一个「是不是只读」的标记,模型看不见,但审批门要靠它区分「直接放行」还是「先问一句」。这类字段要在第一版就定下来,等有了十五个工具再回来补一遍,成本高得多。
- 可预期的追问一:工具该有多少个?超过二三十个之后模型的选择准确率会掉,处理办法是分组按需加载(渐进披露),而不是把描述写得更长。追问二:描述该多长?判据是「删掉这句话,模型会不会用错」——不会就删掉,因为每个工具的描述都占系统提示的预算,每一轮都要重发。
How to reason about it · think before answering
- It looks like a giveaway, but answering write it clearly scores nothing. The signal is knowing what the model actually sees: only the name, the description, and the parameter schema. Implementation is invisible to it, so this is an interface design question, not a writing question.
- How to break it down: derive the rules from the mistakes. The model picks the wrong tool when the description never says when to use it; it writes bad syntax when there is no example; it passes junk parameters when defaults are unstated and extra properties are allowed; it misuses one tool half the time when that tool does two jobs.
- Conclusion as four rules: name it verb plus noun in lower snake case; describe when to use it, how to fill the parameters, and what the limits are, with one concrete example; document every parameter including its default; keep one tool to one job and split rather than branch.
- Add the point most people miss: the schema also carries fields for yourself. A read-only flag is invisible to the model but is what an approval gate uses to decide whether to pause. Define such fields in the first version, because retrofitting fifteen tools later is far more expensive.
- Likely follow-ups: how many tools? Selection accuracy degrades past a couple dozen, and the fix is grouping and on-demand loading rather than longer descriptions. How long should a description be? Delete a sentence and ask whether the model would now misuse the tool; if not, delete it, because every description is resent in the prompt on every turn.
答题要点
- 模型只看见名字、描述、参数 schema 三样,所以这是接口设计问题
- 名字用动词加名词、小写下划线;描述写「什么时候用、参数怎么填、有什么限制」并给例子
- 每个参数写说明与默认值,关掉额外属性;一个工具只做一件事
- schema 里还要有给自己看的字段,例如只读标记,供审批门使用,第一版就定下来
- 工具太多要分组按需加载,描述长度的判据是「删掉它模型会不会用错」
Key points
- The model sees only the name, the description, and the parameter schema, so this is interface design
- Verb-plus-noun lower snake case names; descriptions cover when to use it, how to fill parameters, and limits, with an example
- Document every parameter and its default, disallow extra properties, and keep one tool to one job
- The schema also carries self-facing fields such as a read-only flag for the approval gate, defined in version one
- Group and lazily load tools when there are many; test description length by whether deleting a line causes misuse
流式返回的工具调用参数怎么归并?有哪些假设是不能做的?How do you reassemble streamed tool-call arguments, and which assumptions are off-limits?
国内高频海外高频深入#tool-calling#streaming分析过程 · 先想清楚再作答
- 这题几乎是本课最有区分度的一道:自己归并过的人两句话说清,没写过的人只能答「把参数拼起来」。题眼在后半句——「不能做的假设」,那是踩过坑才有的清单。
- 怎么拆:先描述真实形状。第一片带调用 id 与函数名、参数是空串;后面每片只带一段参数文本;一次调用实测能切成六到十二片;结束时给一个「要调工具」的结束原因。所以归并的动作是:按序号找到槽位,id 与名字「有值才写」,参数累加。
- 接着列不能做的假设,每一条都有对应的事故:一,不能假设序号从 0 开始——2026 年 9 月 7 日实测同一个模型在同一家网关的两次运行分别给出 1 和 2、以及 0 和 1 两种起点,所以要用字典而不是数组下标;二,不能按到达顺序拼,因为并行调用的分片是交错到达的,按顺序拼会把两个调用的参数缝成一个;三,不能拿到一片就试着解析 JSON,切分点可能在引号或转义符中间;四,不能只信结束原因这一个字段,实测有模型报「正常停止」却仍然给了工具分片。
- 结论:归并的正确形状是「按序号建字典、累加参数、按序号升序取出」,解析放在全部分片到齐之后,判据用「结束原因是工具调用,或者归并出了至少一个调用」的并集。
- 工程视角补一条:id 也可能缺。缺了要自己造一个稳定的标识,因为工具结果消息必须能指回某个调用,少一条对应关系,下一轮请求就不合法。
- 可预期的追问:怎么测这个逻辑?真实网关上这个 bug 有一半概率不出现,所以要在离线剧本里故意让序号从 1 开始、把参数切得很碎、并让两个调用交错到达。把偶发变成必然,是这类协议代码唯一可靠的测法。
How to reason about it · think before answering
- This is one of the most discriminating questions in the course: people who have done it answer in two sentences, and people who have not can only say concatenate the arguments. The signal is the second half, the list of forbidden assumptions.
- How to break it down: describe the real shape first. The first delta carries the call id and function name with empty arguments; later deltas carry argument fragments only; one call was measured to arrive in six to twelve pieces; the stream ends with a tool-calls finish reason. So merging means finding the slot by index, writing id and name only when present, and appending arguments.
- Then the forbidden assumptions, each tied to a real incident. You cannot assume the index starts at zero: on 2026-09-07 the same model on the same gateway produced starts of 1 and 2 in one run and 0 and 1 in another, so use a dictionary rather than array positions. You cannot merge by arrival order, because parallel calls interleave and you would stitch two argument strings together. You cannot JSON-parse each fragment, because splits land inside quotes and escapes. And you cannot trust the finish reason alone, since a model was observed reporting stop while still emitting tool deltas.
- Conclusion: keep a dictionary keyed by index, append argument text, drain sorted by index, parse only once everything has arrived, and treat the turn as a tool turn if either the finish reason says so or at least one call was merged.
- One more engineering point: the id can be missing. Generate a stable one, because every tool result message must point back to a call, and a missing pairing makes the next request invalid.
- Likely follow-up: how do you test it? The bug shows up maybe half the time against a real gateway, so the offline script should deliberately start indexes at 1, chop arguments finely, and interleave two calls. Turning an intermittent failure into a certain one is the only reliable way to test protocol code.
答题要点
- 真实形状:第一片带 id 与函数名,后面每片只带参数增量,一次调用实测六到十二片
- 归并动作:按序号建字典、id 与名字有值才写、参数累加、按序号升序取出
- 四个不能做的假设:序号从 0 起、按到达顺序拼、每片都解析一次、只信结束原因字段
- id 可能缺,要自己造一个稳定标识,否则工具结果指不回调用,下一轮请求不合法
- 测法是在离线剧本里让序号从 1 起、参数切碎、两个调用交错,把偶发变必然
Key points
- Real shape: the first delta carries id and name, later deltas carry argument fragments, six to twelve pieces per call in practice
- Merge by keeping a dictionary keyed by index, writing id and name only when present, appending arguments, and draining sorted by index
- Four forbidden assumptions: zero-based indexes, arrival-order merging, parsing each fragment, and trusting the finish reason alone
- Ids can be missing, so synthesize a stable one or the tool result cannot point back and the next request is invalid
- Test it by making the offline script start at index 1, split arguments finely, and interleave two calls
工具结果远超上下文预算时,你的截断策略是什么?怎么保证不截掉关键信息?When a tool result blows past your context budget, what is your truncation strategy, and how do you avoid cutting the part that matters?
国内高频海外高频进阶#context-budget#tool-design分析过程 · 先想清楚再作答
- 这题在考「有没有算过账」。答「截到一定长度」的人没意识到问题的真实形状:工具结果要回灌进消息数组,而消息数组每一轮都整体重发一次——所以一次超长结果的成本不是一次,是剩下所有轮次乘以一次。
- 怎么拆:先算清代价,再定策略。一次命中两千行的搜索大约六万字符,转五圈就付五次;更糟的是它挤掉了真正重要的上下文——用户的需求、之前读到的关键代码。于是结论很自然:单个结果必须有硬上限。
- 然后是「怎么截」。只留头是最常见的错法,因为尾部往往有结论性的信息:报错的最后一行、测试的汇总行、文件末尾的导出清单。所以从中间截、头尾都留,头可以多分一点,因为最相关的内容通常在前面。
- 第三步是「截了要说」。中间必须插一行说明:截掉了多少字符、全文多少字符、想看中间那段该怎么做(带偏移量再读一次)。模型看不懂省略号,但看得懂一条指令。这一行是策略里最容易被漏掉、却最有效的部分。
- 结论加一条位置判断:截断放在工具执行的统一入口,不放在每个工具里。工具作者只管把内容做对,预算由一处统一管——不然二十个工具会有二十份截断逻辑,且各不相同。另外「已被截断」这个标记只放在给渲染看的元数据里,不回灌给模型,省下的也是 token。
- 可预期的追问一:更好的做法有没有?有——让工具自己支持分页(偏移量与条数),并在描述里告诉模型怎么用,比事后截断优雅得多,截断是最后一道保险。追问二:整体预算怎么分?那是压缩那一层的题目,本层只保证单个结果不无限大。
How to reason about it · think before answering
- This checks whether you did the arithmetic. Answering cut it to some length misses the shape of the problem: tool results are fed back into the message array, and the whole array is resent every turn, so one oversized result costs once per remaining turn, not once.
- How to break it down: price it, then design. A search hitting two thousand lines is roughly sixty thousand characters, billed again on every subsequent turn, and worse, it crowds out what matters — the user's request and the code you already read. Hence a hard per-result cap.
- Then how to cut. Head-only is the common mistake, because the tail usually holds the conclusion: the last line of a stack trace, a test summary, the export list at the end of a file. So cut from the middle and keep both ends, weighting the head slightly since the most relevant content tends to come first.
- Third, say that you cut. Insert a line stating how many characters were removed, the full length, and what to do to get the middle, such as reading again with an offset. The model cannot interpret an ellipsis but can follow an instruction, and this line is the most commonly omitted yet most effective part of the strategy.
- Also decide where truncation lives: in the single tool-execution entry point, not in each tool. Tool authors get the content right and one place owns the budget, otherwise twenty tools grow twenty different truncation rules. Keep the truncated flag in render-only metadata rather than feeding it back, which saves tokens too.
- Likely follow-ups: is there something better? Yes — give tools real pagination with offset and count and document it in the description; truncation is the last line of defense. And how is the overall budget split? That belongs to the compaction layer; this layer only guarantees no single result is unbounded.
答题要点
- 先算代价:结果回灌后每一轮都整体重发,一次超长结果的成本是剩余轮次乘以一次
- 从中间截、头尾都留,头多分一点;只留头会丢掉报错末行与汇总行这类结论信息
- 必须插一行说明:截掉多少、全文多少、想看中间怎么做,模型看不懂省略号但看得懂指令
- 截断放在工具执行的统一入口,不放在每个工具里;已截断标记只给渲染看不回灌
- 更好的做法是工具自带分页并写进描述,截断是最后一道保险;整体预算分配属于压缩那一层
Key points
- Price it first: results are resent every turn, so one oversized result costs once per remaining turn
- Cut from the middle keeping both ends with a heavier head; head-only loses conclusions like final error lines and test summaries
- Insert a note with how much was removed, the total length, and how to fetch the middle, since the model follows instructions rather than ellipses
- Truncate at the single tool-execution entry point, and keep the truncated flag in render-only metadata
- Better still, give tools real pagination documented in the description; truncation is the last resort, and overall budget split belongs to compaction