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、.git、dist、build、vendor 等。
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 + metaLLM 收到的不仅是文件内容,还知道总共多少行、当前展示范围、如何继续读。这让 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 还没产生任何文本就报错时,需要主动创建文本节点来展示错误,而不是尝试追加到不存在的文本节点上。