引言
在 LLM Agent 系统中,工具调用是 Agent 与环境交互的核心手段。bash 命令的执行结果、网页抓取的内容、代码搜索的匹配行——这些输出可能非常庞大。如果不加限制地塞给 LLM,既浪费 token 也超出窗口长度;如果粗暴截断,又可能丢失信息。
KiloCode 的工具输出处理系统,核心是 ToolOutputStore。本文从源码出发,逐层解析这套机制。
总体架构
整个系统分为三个层次:
进程级截断 → ToolOutputStore 截断 → 分页读取恢复
(内存安全) (磁盘持久化 + 预览) (LLM 按需拉取)
- 进程级:bash 工具运行时,stdout/stderr 超过 1MB 即截断,防止 OOM
- ToolOutputStore:工具返回结果时,若超过 2000 行或 50KB,写入磁盘并返回头尾预览 + URI
- 分页读取:LLM 看到截断标记后,可调用
read工具传入tool-output://{id}+ offset/limit 拉取完整内容
核心:ToolOutputStore
文件:packages/core/src/tool-output-store.ts
阈值常量
export const MAX_LINES = 2_000
export const MAX_BYTES = 50 * 1024 // 50 KB
export const MAX_READ_BYTES = 50 * 1024 // 每页读取限制
export const RETENTION = Duration.days(7) // 7 天自动清理
写入的阈值是 50KB,读取的单页限制也是 50KB。两者一致的设计意图是:截断后的大多数内容恰好一页读完,LLM 不需要处理分页逻辑。这种「一次读取」的假设降低了系统的复杂度——不需要实现游标、不需要维护读取状态,每个 read 调用都是自包含的。
数据结构
export type TruncateResult =
| { readonly content: string; readonly truncated: false }
| { readonly content: string; readonly truncated: true; readonly resource: Resource }
设计决策:未超过阈值时,不做任何额外操作(不写磁盘、不分配 ID),直接原样返回。这样做的理由:大部分工具调用结果远小于阈值,如果每次返回都走磁盘 I/O 和 ID 分配,会给高频小输出场景带来无谓开销。只有超出阈值时才升级为持久化路径。
核心逻辑:truncate 方法
const truncate = Effect.fn("ToolOutputStore.truncate")(function* (input: TruncateInput) {
const configured = yield* limits()
const maxLines = input.maxLines ?? configured.maxLines
const maxBytes = input.maxBytes ?? configured.maxBytes
if (input.content.split("\n").length <= maxLines && Buffer.byteLength(input.content, "utf-8") <= maxBytes) {
return { content: input.content, truncated: false } as const
}
const resource = yield* write(input)
const bounded = preview(input.content, maxLines, maxBytes)
const marker = `... output truncated; full content available as ${resource.uri} ...`
return {
content: bounded.tail ? `${bounded.head}\n\n${marker}\n\n${bounded.tail}` : `${bounded.head}\n\n${marker}`,
truncated: true,
resource,
} as const
})
流程:
- 从配置或默认值获取阈值
- 检查是否在限制内——是则直接返回
- 否则写入磁盘,生成头尾预览,插入 URI 标记
预览策略:头尾保留
const preview = (text: string, maxLines: number, maxBytes: number) => {
const lines = text.split("\n")
const headLines = Math.ceil(maxLines / 2)
const tailLines = Math.floor(maxLines / 2)
// ...
}
保留头部 ceil(maxLines/2) 行和尾部 floor(maxLines/2) 行。理由:
- bash 命令的输出,开头往往是命令本身或标题行,结尾往往是退出码或汇总信息
- webfetch 的结果,开头是主要内容摘要,结尾可能是引用
- LLM 有
read工具可以随时补读中间部分
这比「只保留前 N 行」更实用——LLM 最容易获得上下文的两端,中间缺失时主动去补。
实现细节上,preview 先用行数界限计算头尾,再检查字节数。如果行数截断后的采样仍超过字节限制,则退化为字节级截断——分别从开头和结尾各取 ceil(maxBytes/2) 字节。这种退化路径覆盖了极端场景(如单行上百万字符的输出),确保预览不会因单行过长而超出 token 预算。
磁盘存储格式
每个截断输出在 {data}/tool-output/managed/ 下存为一对文件:
{id}.txt → 完整原始内容
{id}.json → 元数据(version, id, uri, sessionID, toolCallID, mime, name, size, created)
ID 由 Identifier.ascending() 生成,格式为 12位十六进制 + 14位字母数字。使用单调递增 ID 而非 UUID 的好处:目录按文件名排序自然等于按创建时间排序,调试时容易定位近期资源。
元数据与内容分离的设计有两个工程意义:一是读取时先解析 JSON 验证完整性(size 字段用于校验 payload 是否被后续篡改或写坏),确认无误才读取 .txt 文件;二是清理时只需扫描 JSON 文件即可判断保留与否,不必读入大文件内容。
会话隔离
if (record.sessionID !== input.sessionID) {
return yield* Effect.fail(new AccessDeniedError({ uri: input.uri, sessionID: input.sessionID }))
}
只有创建该资源的同一会话才能读取。这防止了跨会话的信息泄露——不同的 agent 子会话各自只能访问自己的工具输出。
7 天保留期
cleanup 方法每小时运行一次,清理:
- 超过 7 天的资源
- 孤立的
.txt文件(有内容无元数据,通常是写入过程中进程崩溃的残留) - 元数据损坏或大小不匹配的配对(payload 被外部修改或写损坏)
7 天的保留期是基于一个经验假设:一次 agent 会话的典型生命周期在数小时到数天之间,7 天足够覆盖异常长的会话,同时不至于无限堆积磁盘。清理策略覆盖了「先写 .txt 后写 .json」两步之间的崩溃窗口——如果进程在写完 .txt 但未写完 .json 时退出,cleanup 会在 1 小时内发现并清除孤立文件。
哪些工具使用了截断?
四个内置工具在返回前调用 resources.truncate():
bash 工具
packages/core/src/tool/bash.ts
bash 同时有进程级截断和存储级截断两层:
const result = yield* appProcess.run(command, {
maxOutputBytes: MAX_CAPTURE_BYTES, // 1MB
maxErrorBytes: MAX_CAPTURE_BYTES, // 1MB
})
const truncated = yield* resources.truncate({
sessionID,
toolCallID: call.id,
content: compact,
})
一个细节在第 189-191 行:
...(truncated.truncated && !result.stdoutTruncated && !result.stderrTruncated
? { resource: truncated.resource }
: {}),
如果 stdout/stderr 已经被进程级截断(超过 1MB),就不把 tool-output:// URI 暴露给 LLM。输出超过 1MB 时,完整读取会直接撑爆上下文窗口、浪费大量 token,且对完成任务无实质帮助。只让 LLM 看到头尾预览,更可能提取到有价值的信息。
webfetch 工具
packages/core/src/tool/webfetch.ts
最大响应 5MB,超出即拒绝。这个策略与 bash 不同:bash 的 1MB 是截断(保留前 1MB),webfetch 的 5MB 是拒绝(不保留任何内容)。原因在于 webfetch 的响应体需要完整解析(HTML→Markdown 转换),截断后再转换会产生残缺的 Markdown,不如直接拒绝并由 LLM 自行决定是否采取其他方式。
websearch 工具
packages/core/src/tool/websearch.ts
搜索结果文本通过 truncate() 处理,MAX_RESPONSE_BYTES = 256KB。
skill 工具
packages/core/src/tool/skill.ts
技能加载内容也经过截断——部分组织不善的 skill 文件可能意外地非常大。
如何读取截断内容?
LLM 通过 read 工具读取截断的完整内容:
文件:packages/core/src/tool/read.ts
const ResourceInput = Schema.Struct({
resource: Schema.String, // 例如 "tool-output://{id}"
offset: NonNegativeInt.pipe(Schema.optional),
limit: PositiveInt.check(...), // 最多 50KB
})
当 read 检测到 "resource" in input 时,路由到 ToolOutputStore.read() 而非文件系统读取。这种统一接口让 LLM 无需区分读取的是本地文件还是托管资源——read 工具在参数层做分发,对 LLM 透明。
分页读取返回 Page 结构:
export class Page extends Schema.Class({...})({
resource, // Resource 元数据
content, // 本页内容
offset, // 字节偏移
truncated, // 是否还有后续
next, // 下一页的偏移
})
LLM 的典型读取模式:
- 收到
... output truncated; full content available as tool-output://abc123 ... - 调用
read({ resource: "tool-output://abc123" })读第一页 - 若
truncated: true,用返回的next偏移继续:read({ resource: "tool-output://abc123", offset: 51200 }) - 直到
truncated: false
配置可定制
用户可以通过配置文件调整阈值:
// V1 配置文件
tool_output: {
max_lines: 2000, // 最大行数
max_bytes: 51200, // 最大字节数
}
系统启动时从配置层读取:
const limits = Effect.fn("ToolOutputStore.limits")(function* () {
if (Option.isNone(config)) return { maxLines: MAX_LINES, maxBytes: MAX_BYTES }
const configured = Object.assign(
{},
...entries.flatMap((entry) => (entry.type === "document" ? [entry.info.tool_output ?? {}] : [])),
)
return { maxLines: configured.max_lines ?? MAX_LINES, maxBytes: configured.max_bytes ?? MAX_BYTES }
})
进程级截断:最后一道防线
packages/core/src/process.ts 中的 collectStream。这个函数是进程级的最后一道保险,在流式读取时实时检查累积字节,超过 maxOutputBytes 后后续数据直接丢弃。它在 bash 工具返回结果之前就完成了截断,因此 ToolOutputStore 看到的内容已经是截断后的子集。
export const collectStream = (stream, maxOutputBytes) =>
Stream.runFold(
stream,
() => ({ chunks: [], bytes: 0, truncated: false }),
(acc, chunk) => {
const remaining = maxOutputBytes - acc.bytes
if (remaining > 0) acc.chunks.push(remaining >= chunk.length ? chunk : chunk.slice(0, remaining))
acc.bytes += chunk.length
acc.truncated = acc.truncated || acc.bytes > maxOutputBytes
return acc
},
)
这个函数在流式读取进程中实时截断:一旦累积字节超过限制,后续数据直接丢弃。这是内存安全的一道防线,防止大输出耗尽 Agent 进程内存。
文件系统层面的行和字节截断
与 ToolOutputStore 平行,packages/core/src/filesystem.ts 中还有一套文件读取截断:
export const MAX_READ_LINES = 2_000
export const MAX_READ_BYTES = 50 * 1024
const MAX_LINE_LENGTH = 2_000
- 单行超过 2000 字符则截断并追加
... (line truncated to 2000 chars) - 分页读取时同时约束行数和字节数
- 目录列表同样有截断限制
ripgrep 结果截断
packages/core/src/ripgrep.ts:
- 单条 JSON 记录超过 64KB 则报错
- 匹配数量超过
limit时设置truncated: true - grep/glob 工具在返回给 LLM 的消息末尾附加截断警告
设计要点
- 分层防护:进程级(1MB)→ 存储级(50KB/2000行)→ 分页读取,三层递进
- 零开销路径:小输出不走磁盘、不分配 ID,直接穿透
- 头尾预览:保留输出的两端,中间按需补读
- 会话隔离:subagent 只能读自己的截断输出,避免跨会话信息泄露
- 自动清理:7 天保留期 + 孤儿文件检测,无需运维介入
- 可配置:阈值通过用户配置覆盖,适应不同场景
总结
这套设计背后有三个隐含假设。
第一,LLM 只在确实需要时才读取截断的完整内容。如果每次截断 LLM 都去拉取全文,那么截断就失去了意义——磁盘 I/O 和上下文注入的成本一样高昂。系统依赖 LLM 的按需读取行为来达到效果。
第二,头尾预览在大多数场景下足够 LLM 做出判断。bash 命令的退出码、grep 的匹配行数、搜索结果的摘要——这些信息通常出现在输出两端。LLM 先看到预览,决定是否需要更多细节,而不是被动接收全部内容。
第三,磁盘是保底而不是默认路径。多数工具调用的输出只有几十到几百字节,不足以触发截断阈值,系统直接以字符串形式返回,不涉及磁盘写入。截断和持久化仅在输出大到影响上下文窗口时才触发——这是对常见场景的优化:让小输出走快速路径,大输出才走持久化路径。
对比更简单的两种方案:全部截断不存(丢失信息)或全部存不截断(浪费 token),ToolOutputStore 选择了中间路径。