Skip to content
返回

KiloCode 工具输出截断与持久化

引言

在 LLM Agent 系统中,工具调用是 Agent 与环境交互的核心手段。bash 命令的执行结果、网页抓取的内容、代码搜索的匹配行——这些输出可能非常庞大。如果不加限制地塞给 LLM,既浪费 token 也超出窗口长度;如果粗暴截断,又可能丢失信息。

KiloCode 的工具输出处理系统,核心是 ToolOutputStore。本文从源码出发,逐层解析这套机制。

总体架构

整个系统分为三个层次:

进程级截断 → ToolOutputStore 截断 → 分页读取恢复
   (内存安全)      (磁盘持久化 + 预览)     (LLM 按需拉取)

核心: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
})

流程:

  1. 从配置或默认值获取阈值
  2. 检查是否在限制内——是则直接返回
  3. 否则写入磁盘,生成头尾预览,插入 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)。理由:

这比「只保留前 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 天的保留期是基于一个经验假设:一次 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 的典型读取模式:

  1. 收到 ... output truncated; full content available as tool-output://abc123 ...
  2. 调用 read({ resource: "tool-output://abc123" }) 读第一页
  3. truncated: true,用返回的 next 偏移继续:read({ resource: "tool-output://abc123", offset: 51200 })
  4. 直到 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

ripgrep 结果截断

packages/core/src/ripgrep.ts

设计要点

  1. 分层防护:进程级(1MB)→ 存储级(50KB/2000行)→ 分页读取,三层递进
  2. 零开销路径:小输出不走磁盘、不分配 ID,直接穿透
  3. 头尾预览:保留输出的两端,中间按需补读
  4. 会话隔离:subagent 只能读自己的截断输出,避免跨会话信息泄露
  5. 自动清理:7 天保留期 + 孤儿文件检测,无需运维介入
  6. 可配置:阈值通过用户配置覆盖,适应不同场景

总结

这套设计背后有三个隐含假设。

第一,LLM 只在确实需要时才读取截断的完整内容。如果每次截断 LLM 都去拉取全文,那么截断就失去了意义——磁盘 I/O 和上下文注入的成本一样高昂。系统依赖 LLM 的按需读取行为来达到效果。

第二,头尾预览在大多数场景下足够 LLM 做出判断。bash 命令的退出码、grep 的匹配行数、搜索结果的摘要——这些信息通常出现在输出两端。LLM 先看到预览,决定是否需要更多细节,而不是被动接收全部内容。

第三,磁盘是保底而不是默认路径。多数工具调用的输出只有几十到几百字节,不足以触发截断阈值,系统直接以字符串形式返回,不涉及磁盘写入。截断和持久化仅在输出大到影响上下文窗口时才触发——这是对常见场景的优化:让小输出走快速路径,大输出才走持久化路径。

对比更简单的两种方案:全部截断不存(丢失信息)或全部存不截断(浪费 token),ToolOutputStore 选择了中间路径。


KiloCodeLLMSystem DesignTool OutputAgent

Previous Post
从 5.4 秒到 106 毫秒:一个 EventV2 批量写入问题的定位与修复
Next Post
KiloCode 上下文组装与提示缓存深度解析