StoneRen

Tool Use 的小坑:一个 grep 如何炸掉整个上下文窗口

Apr 21, 2026 ·
0

Tip

一次 grep TODO命令执行,直接让 LLM API 报 context window exceeds limit。本文记录排查过程、解决方案,以及由此总结的工具系统最佳实践。

问题发现

ai-sails 项目中测试权限系统时,发送了"搜索 TODO 关键词"——一个再普通不过的请求。后端返回了错误,但前端没有任何异常提示,对话直接静默失败。

案发现场

查看 debug 日志,最后一条记录:

error: 400 "invalid_request_error"
message: "invalid params, context window exceeds limit (2013)"

但会话数据只有 7 条消息、3.6KB——远没到 200K token 的限制。

根因定位

检查 debug 日志中 grep 工具的返回值:

request  → messageCount: 7, lastUserMessage: "搜索 TODO 关键词"
tool-use → grep({ pattern: "TODO" })
tool-result → content length: 7,008,205 chars (7MB!)

grep 搜索了整个项目目录,包括 node_modules。7MB 的工具结果被原封不动地塞进了对话历史,下一轮发给 LLM API 时直接爆掉了上下文窗口。

核心问题链路:

grep 没有 exclude → 返回 7MB → 写入 history → 发给 API → context limit exceeded

解决方案:两层防御

第一层:工具输出硬限制

每个工具的返回结果设置上限,超出截断并附带提示。这是零成本的安全网。

第二层:对话历史管理(Context Window Manager)

独立模块,在每次 LLM 调用前估算 token 总量,超限时逐级降级:

原始历史 → token 估算 → 是否超限?
  策略1: compressToolResults  → 压缩大工具结果
  策略2: slidingWindow(6)     → 保留最近6轮
  策略3: slidingWindow(2)     → 保留最近2轮
  策略4: drop-oldest          → 从最早的消息逐条丢弃,直到不超限(保留系统提示)

Context 管理策略

用户发送消息 → query-loop 构建 history

      ▼ 每轮 LLM 调用前

  manageContext(history)

      ├─ estimateTokens(history) → chars / 4

      ├─ tokens <= budget (默认150K)?
      │   YES → 原样返回,不做任何处理

      │   NO → 进入三级降级

      ├─ 策略1: compressToolResults
      │   扫描所有 toolResults,content > 5000 chars 的
      │   替换为 "[tool result truncated: 89234 chars]"
      │   │
      │   ├─ 仍超限? → 继续
      │   └─ 不超限 → 返回,附带日志

      ├─ 策略2: slidingWindow(6)
      │   按Turn切割(以非tool-result的user消息为边界)
      │   保留: [第1个Turn(系统提示)] + [最近6个Turn]
      │   │
      │   ├─ 仍超限? → 继续
      │   └─ 不超限 → 返回,附带日志

      ├─ 策略3: slidingWindow(2)
      │   只保留: [第1个Turn] + [最近2个Turn]
      │   │
      │   ├─ 仍超限? → 抛错
      │   └─ 不超限 → 返回,附带日志


  provider.stream(managedHistory)

第一版实现的问题

初始实现统一使用 50K chars 截断,上线后发现几个关键不足:

问题影响
截断阈值统一偏高grep 4K token 就够了,50K 等于没截
截断信息没价值单纯的[truncated, 89234 total] 对 LLM 没有指导意义
grep 截断破坏结构按字符截断可能切断 file:line:content 格式,LLM 无法解析
bash 合并后截断stderr 在尾部,合并截断容易丢掉错误信息
read 无元信息LLM 不知道文件有多少行,无法决定是否继续读

优化方案

1. Per-Tool 配置化限制

创建 src/tools/config.ts,每个工具有独立的 token 限制,支持环境变量覆盖:

// src/tools/config.ts
export const TOOL_LIMITS = {
  read:       loadLimit('TOOL_READ_MAX_TOKENS',  8000),
  bash:       loadLimit('TOOL_BASH_MAX_TOKENS',  6000),
  bashStderr: loadLimit('TOOL_BASH_STDERR_MAX_TOKENS', 2000),
  grep:       loadLimit('TOOL_GREP_MAX_TOKENS',  4000),
  grepMaxLines: loadLimit('TOOL_GREP_MAX_LINES', 200),
  glob:       loadLimit('TOOL_GLOB_MAX_TOKENS',  2000),
}

2. 有价值的截断信息

// 不好
"[truncated, 89234 total]"

// 好
"[truncated: showing 200 of 2341 lines | 89234 total chars | narrow your pattern]"

截断函数设计:

// src/tools/truncate.ts
export function truncateByLines(content: string, maxLines: number, hint?: string): string {
  const lines = content.split('\n')
  if (lines.length <= maxLines) return content

  const shown = lines.slice(0, maxLines).join('\n')
  return shown + formatTail(content.length, lines.length, maxLines, hint)
}

3. grep:行数优先截断

grep 输出是 file:line:content 结构,按字符截断会破坏格式。策略:先限行数,再限字符

// src/tools/grep.ts
return truncateWithStrategy(output, {
  maxLines: TOOL_LIMITS.grepMaxLines.maxTokens,  // 先 head -200
  maxChars: TOOL_LIMITS.grep.maxChars,            // 再字符截断
  hint: 'narrow your pattern or add --include to reduce matches',
})

同时增加了 18 个默认排除目录:node_modules.gitdistbuildvendor 等。

4. bash:stdout/stderr 分离截断

// src/tools/bash.ts — 修改前后对比

// ❌ 之前:合并后截断,stderr 容易被丢弃
let result = stdout + '\n[stderr] ' + stderr
return truncateOutput(result)

// ✅ 现在:分别截断后拼接
const parts: string[] = []
if (stdout)
  parts.push(`stdout:\n${truncateByChars(stdout, TOOL_LIMITS.bash.maxChars)}`)
if (stderr)
  parts.push(`stderr:\n${truncateByChars(stderr, TOOL_LIMITS.bashStderr.maxChars)}`)

这样即使 stdout 很大,stderr 的错误信息也不会被截断丢掉。

5. read:返回元信息供 LLM 决策

// src/tools/read.ts
const meta = `\n\n[file: ${filePath} | total: ${totalLines} lines | shown: ${start + 1}-${shownEnd}${
  end < totalLines ? ` | use offset=${shownEnd} to continue` : ''
}]`
return content + meta

LLM 收到的不仅是文件内容,还知道总共多少行、当前展示范围、如何继续读。这让 LLM 能主动决策而不是盲目猜测。

6. grep 默认排除 + 可选绕过

// grep.ts
const EXCLUDE_DIRS = ['node_modules', '.git', 'dist', 'build', ...]

if (!args.noIgnore) {
  for (const dir of EXCLUDE_DIRS)
    cmd.push('--exclude-dir', dir)
}

默认排除,但如果 LLM 判断需要搜索 node_modules,可以通过 noIgnore: true 显式开启——安全但不死板。

Context Window Manager 实现

独立模块 src/agent/context-manager.ts,集成在 query-loop 的每次 LLM 调用前:

// src/agent/query-loop.ts
for (let turn = 0; turn < MAX_TURNS; turn++) {
  // 每轮调用前都经过 context manager
  for await (const chunk of provider.stream(manageContext(history), toolDefs)) {
    // ...
  }
}

三级降级策略:

export function manageContext(messages: LLMMessage[]): LLMMessage[] {
  if (!isOverBudget(messages)) return messages

  // 策略1: 压缩大工具结果为摘要
  let current = compressToolResults(messages)
  if (!isOverBudget(current)) return current

  // 策略2: 滑动窗口,保留最近 6 轮
  current = slidingWindow(current, 6)
  if (!isOverBudget(current)) return current

  // 策略3: 只保留最近 2 轮
  current = slidingWindow(current, 2)
  if (!isOverBudget(current)) return current

  throw new Error('对话历史过长,请开始新会话。')
}

滑动窗口按"轮次"(Turn)切割,保证 tool-call 和 tool-result 的配对不被拆散:

function splitIntoTurns(messages: LLMMessage[]): Turn[] {
  // 以非 tool-result 的 user 消息作为轮次边界
  const isTurnStart = msg.role === 'user' && !msg.toolResults?.length
}

最佳实践总结

1. 工具输出永远要有上限

工具输出是上下文膨胀的主要来源。每个工具都应该有明确的最大输出限制,且根据工具特性差异化设定。

2. 截断策略要匹配数据结构

  • grep 的 file:line:content 结构 → 按行截断优先
  • bash 的 stdout/stderr → 分离截断后合并
  • read 的文件内容 → 附带元信息

3. 截断信息要对 LLM 有指导意义

告诉 LLM "有多少被省略了"以及"下一步怎么做",而不是只说"被截断了"。

4. 默认排除 + 显式开启

安全策略应该是默认生效的(排除 node_modules),但不应该完全锁死(提供 noIgnore 选项)。

5. 上下文管理是系统级问题

不能只靠单个工具的输出限制,还需要在对话层面进行管理——压缩历史、滑动窗口、优雅降级。

6. 前端错误处理不能静默丢弃

后端错误(如 context limit)必须显示给用户。当 LLM 还没产生任何文本就报错时,需要主动创建文本节点来展示错误,而不是尝试追加到不存在的文本节点上。

PRESENT 2026 ©StoneRenver:2604271237