这是《Agent全栈实战》的第 6 篇。整个系列以 catbuddy(一个本地优先的 AI 编程助手,约 3.6 万行 TypeScript)为案例,由浅入深拆解 harness 设计。前面我们讲了心脏(Agent Loop)和手脚(工具系统)。这一篇聊「眼睛」------模型每次开口之前,它到底看到了什么 。你给 LLM 的那一长串
messages,不是随手拼的,而是一条五层流水线装出来的;而对话一长,这串东西就会撑爆 上下文窗口,撞上窗口红线直接context_length_exceeded。所以这篇是一条完整的线:先看怎么拼出来 ,再看为什么会爆 ,最后看 catbuddy 怎么治理让它不爆。一篇看完,你能把「上下文工程」从装配到自愈讲给同事听。
先问一个你可能没细想的问题
你调 LLM 的时候,传进去的是个 messages 数组,对吧?
php
await llm.chat({ system: "...", messages: [...] })
那个 system 里到底是什么?那个 messages 数组又是谁拼的?
大多数人写 demo 的时候,system 是一句写死的 "You are a helpful assistant",messages 就是把历史 push 进去完事。能跑。但你把它放进一个真正干活、连续聊几百轮的 Agent 里,两个问题立刻冒出来:
- 拼的问题:这个 Agent 的人格、它能用哪些工具、它记得用户什么事、当前在哪个项目------这些信息从哪来,按什么顺序塞进去?拼错了顺序、漏了一块,模型的「视野」就是残缺的。
- 爆 的问题:聊到第 200 轮,历史
messages累计的 token 早就超过窗口了。下一次调 LLM,provider 直接给你甩一个context_length_exceeded------任务当场崩。
catbuddy 的「眼睛」就是为这两件事生的。它由两半组成:一半负责装配 (ContextBuilder,把视野拼出来),一半负责治理 (AgentRunner._governContext,让视野不爆)。我们一个一个看。
装配侧:ContextBuilder 的五层流水线
先看「拼」。
打开 catbuddy 的日志,你会看到发给 LLM 的 system 大概长这样:
javascript
You are catbuddy 🐱, a smart and caring cat-spirit AI assistant.
OS: darwin / Node.js v20.14.0 Work root: /Users/.../my-project
---
## AGENTS.md ...这个项目里 Agent 该怎么表现...
## SOUL.md ...Agent 的性格设定...
## USER.md ...用户画像:名字、偏好、时区...
## TOOLS.md ...自定义工具使用说明...
---
## Long-Term Memory (You) ...关于你的长期记忆...
## Project Memory ...关于这个项目的长期记忆...
---
## Always-On Skills ...memory / my 技能的完整正文...
## Available Skills - drawio: 生成架构图 - github: 搜索仓库 ...
这不是一个写死的字符串。它是 ContextBuilder(源码在 context/prompt-builder.ts)按固定五层顺序 动态拼出来的。每一层管一类信息,层与层之间用 Markdown 分隔线 --- 隔开------对 LLM 来说,这是一道清晰的视觉边界。

为什么是这个顺序? 因为它遵循「最重要 → 可忽略」的降级原则:先立住「我是谁」,再加「怎么干活」,再补「记得什么」,最后挂上「能用什么」。万一哪天 token 预算真的紧张,从后往前砍也不伤核心。下面逐层快速过一遍。
① SOUL:人格层(一句话带过)
第一层是 Agent 的人格声明------You are catbuddy 🐱...。它不是写死的字符串,而是一个 Handlebars 模板 (identity.md)。Handlebars 是 JS 生态里一个轻量模板引擎,{{变量}} 会被替换成实际值。所以同一个模板,在 Telegram bot 里渲染成「简洁回复」,在桌面端渲染成「完整工作区上下文」------一个模板,多副面孔。
人格工程本身是个大话题(怎么写 SOUL.md 才能让 Agent 既有性格又不胡来),但那是另一篇的事。这里你只需要记住:人格是五层里的第一层,先立人设。
② 引导文件:四个用户能改的 .md
人设立完,加载四个用户可以随手编辑的引导文件,定制这个 Agent 的具体行为:
| 文件 | 装的是什么 | 类比 |
|---|---|---|
| AGENTS.md | 这个项目里 Agent 该怎么表现 | 相当于业界常说的 CLAUDE.md |
| SOUL.md | 说话风格、回复偏好 | 人格的可调旋钮 |
| USER.md | 用户画像:名字、技术栈偏好、时区 | 「我是谁」 |
| TOOLS.md | 自定义工具的使用说明 | 工具的本地备注 |
代码就是老老实实地遍历这四个文件名,读到内容就拼一段 ## 文件名\n\n内容:
javascript
for (const name of BOOTSTRAP_FILES) { // ['AGENTS.md','SOUL.md','USER.md','TOOLS.md']
const content = this.fs.readWorkspaceFile(name)
if (content) parts.push(`## ${name}\n\n${content}`)
}
注意 USER.md 有个特殊待遇:如果开了「全局画像」,它从 ~/.catbuddy/workspace 读------这样你在所有项目里共享同一份用户画像,不用在每个项目里都写一遍「我叫张三,喜欢 TypeScript」。
③ 分层记忆:「关于你」+「关于这个项目」
第三层注入长期记忆,而且分两层:
- Long-Term Memory (You) --- 从全局
MEMORY.md读,是跨项目的用户级记忆(你的技术栈偏好、沟通风格)。 - Project Memory --- 从当前项目的
MEMORY.md读,是项目级记忆(这个项目的架构决策、正在进行的任务)。
这里有个很省 token 的小聪明------isDefaultMemory() 检查:如果 MEMORY.md 还是模板默认内容(说明还没攒下任何记忆),就不注入,免得白白浪费几十个 token 塞一段占位符。
这些记忆是从哪来的?谁往 MEMORY.md 里写的?------这是「记忆」那一篇(第 07 篇)的活。这里你只需要知道:眼睛从记忆里读,但不负责写。 记忆的提炼、巩固、后台学习,全部留到下一篇。
④ 技能:全文 vs 目录摘要
第四层挂技能。catbuddy 把技能分两类,注入策略完全不同:
- 始终在线技能 (
memory、my这两个)------完整SKILL.md正文直接塞进系统提示词。因为这两个太核心,每轮都得在场。 - 按需技能 (drawio、github......其他所有)------只放一行摘要 :
- drawio: 生成架构图。
为什么按需技能只放摘要不放全文?因为一个技能的全文可能很长(drawio 的全文包含整套画图 DSL 指令)。系统提示词里只需要一份目录,告诉模型「你有这些本事」;真正的全文,等模型实际调用那个技能时再通过 Skill 系统注入。这是典型的「目录常驻、正文懒加载」------省 token 的关键。
⑤ 运行时上下文:工具清单按需注入
最后一层是运行时信息------MCP 工具清单按需注入到这一轮该有的位置。MCP(Model Context Protocol)是接外部工具的标准协议,这块第 05 篇详细讲过,这里不展开。
1.5 装配侧最值得抄的两个工程细节
五层拼法你看懂了,但真正体现「工程功力」的是下面两点。它们解决的都是同一个矛盾:系统提示词好几千 token,每次请求都重拼一遍太亏,但又不能拼一次就永远缓存------文件改了得能感知到。
细节一:指纹缓存------用 mtime 当「内容变没变」的探针
ContextBuilder 不会每次 build() 都重新读文件、跑模板、拼字符串。它先算一个指纹,拿指纹当缓存 key:
javascript
buildSystemPrompt(opts): string {
const key = `${channel}:${this._fingerprint()}`
const cached = this.cache.get(key)
if (cached !== undefined) return cached // 命中 → 直接返回,零拼接
const prompt = this._assembleSystemPrompt(channel)
this.cache.set(key, prompt)
return prompt
}
关键是 _fingerprint() 怎么算的------它把所有可能影响系统提示词内容 的文件的 statSync().mtimeMs(修改时间戳)串成一个字符串:四个引导文件、全局记忆、项目记忆、技能目录指纹,再加上工作区路径,用 \0 拼起来。
逻辑朴素到优雅:任何一个文件被编辑 → mtime 变 → 指纹变 → key 变 → 缓存自然失效,下次重建。 不需要文件监听器,不需要事件通知。指纹只做 6-8 个文件的 statSync,每次 < 1ms;省下的是几千 token 的字符串拼接 + 模板渲染。这笔账,太划算。
所以当你改了 SOUL.md、或者后台往 MEMORY.md 写了新记忆、或者装了个新 Skill------下一次调 LLM,眼睛自动看到的就是新的视野,你什么都不用手动刷新。
细节二:运行时信息为什么不放系统提示词
你可能注意到了:当前时间、 channel 这些运行时信息,catbuddy 没有 放进系统提示词,而是拼在第一条 user 消息的末尾:
javascript
const textWithCtx = runtimeCtx
? `${opts.currentMessage}\n\n[runtime: ${runtimeCtx}]` // 时间/channel 拼这儿
: opts.currentMessage
messages.push({ role: 'user', content: textWithCtx })
为什么?因为系统提示词被指纹缓存了,但当前时间是每 毫秒 都在变的 。如果你把 Time: 2026-06-28T10:30:01Z 写进系统提示词,那这个时间戳每秒都不一样,指纹每秒都失效,缓存形同虚设------你等于亲手把缓存砸了。
放进 user 消息就完全合理:每条 user 消息本来就互不相同,不存在缓存可言,时间放这儿不破坏任何东西。这是一个很小但很见功底的取舍------把「易变的东西」和「可缓存的东西」分开放。
为什么会爆:token 撞上窗口红线
视野拼好了,现在看「爆」。
模型的上下文窗口是有上限的------常见的 128K,大的 200K。听起来很大?我们算笔账:
perl
一条用户消息 ~50 tokens
一条 Agent 回复(含思考) ~500-2000 tokens
一次工具调用结果 ~1000-8000 tokens(read_file 读一整个文件 / grep 命中 50 行)
─────────────────────────────────────
一轮完整交互 ~2000-10000 tokens
20 轮 × 平均 5000 = 100K tokens。再来几轮,窗口就满了。
更阴险的是:token 不是按「消息条数」线性涨的。 一次 grep 命中 50 行代码,一条 tool result 就能吃进去 2000 token。而模型对这些原始 grep 输出,其实只需要一个摘要就够了------它正占着大量预算,性价比极低。
于是历史像吹气球一样涨,直到某一轮,累计 token 撞上窗口红线:

这就是所有 AI 助手都躲不掉的坎。你有没有对着某个聊天机器人聊了俩小时,它突然开始胡说八道?不是模型变笨了,是窗口满了、开头被截断了,它丢了「我们之前聊到哪」的记忆------而界面上还显示着完整记录,你根本不知道它已经看不到开头了。
catbuddy 的解法不是粗暴地「从头删」,而是一套先精确测量、再分级治理的机制。
治理侧之一:先把 token 数得准
要治理,先得知道「现在到底用了多少 token」。这一步不能靠估算。
最常见的偷懒办法是 text.length / 4。但这个估算误差大到离谱,因为中英文、代码的 token 密度天差地别:
bash
"Hello world" → 2 tokens (英文约 4 字符/token)
"你好世界" → 8 tokens (中文约 1.5 字符/token)
"print('hello')" → ~5 tokens (代码更密)
catbuddy 用 js-tiktoken(token-counter.ts)------OpenAI 那套 BPE 分词器的纯 JS 移植。BPE(Byte Pair Encoding,字节对编码)的思路是:从字节开始,反复合并最高频的相邻对,形成子词词表。"unbelievable" 可能被切成 ["un", "believ", "able"],每个汉字往往单独成一个 token。这是 GPT 系列真正在用的分词方式,所以数出来是精确值,不是猜。
数一条消息的 token,遵循 OpenAI Cookbook 的规范------content、tool_calls 的参数、name 字段都要算,再加每条消息固定的格式开销:
javascript
const MESSAGE_OVERHEAD = 4 // role + ChatML 分隔符的固定开销
let total = MESSAGE_OVERHEAD
total += encoder.encode(msg.content).length // 正文
if (msg.toolCalls) for (const tc of msg.toolCalls) {
total += encoder.encode(tc.name).length // 工具名
total += encoder.encode(JSON.stringify(tc.arguments)).length // 参数 JSON
}
那个 MESSAGE_OVERHEAD = 4 是 ChatML 格式里 <|im_start|>role\n...<|im_end|> 这套标记的开销。catbuddy 还有个立场叫**「宁可高估,绝不低估」 ------高估只会让你 提前一点开始压缩(功能轻微降级),低估却会 真的超窗**(API 直接报错)。两害相权,当然选前者。另外它还留了一手 safeCountTokens:万一 js-tiktoken 在某些受限环境初始化失败,回退到 length / 3 的粗估------功能有边界,但系统不崩。这种优雅降级在 catbuddy 里到处都是。
精确 计数 到底值多少? 在一个真实会话上实测过:
| 计数方式 | 结果 |
|---|---|
| 粗估(字符 ÷ 4) | 85K tokens |
| js-tiktoken 精确 | 112K tokens |
差了 27K。对 128K 的窗口来说,这 27K 可能正好是「能正常回答」和「开始胡言乱语」之间的那条线。估算误差 30%,在大窗口里就是「能答」和「胡说」的差距。 数得准,你才能在撞线之前就动手,而不是等 API 甩错误回来才慌。
治理侧之二:每轮调 LLM 前的四步自愈
数准了,接下来是真正的治理。
catbuddy 在 AgentRunner.run() 的主循环里,每一轮调 LLM 之前 ,都先跑一遍 _governContext()------一条四步流水线,纯规则、零 LLM 调用、微秒级完成。它把那个会越长越脏的 messages 数组,原地整理到「干净、合法、不超预算」:
javascript
private _governContext(messages: LLMMessage[], spec: RunSpec) {
this._dropOrphanToolResults(messages) // ① 删孤儿
this._backfillMissingToolResults(messages) // ② 补缺失
this._microcompact(messages) // ③ 微压缩
this._snipHistory(messages, spec) // ④ 断头截断
}
这个顺序不是随意的:清理 → 修复 → 压缩 → 截断,每一步给下一步准备好干净的数据。

① _dropOrphanToolResults:删「无主」的工具结果
会话恢复、或者 /stop 中断了一个工具序列时,可能留下「有 tool result 但没有对应 assistant tool_call」的孤儿。LLM API 对消息格式有严格的配对要求------一条孤立的 tool result 会直接破坏格式、触发 API 报错。
做法很直白:先扫一遍所有 tool_call 的 id 收集成集合,再倒序 删掉那些 id 不在集合里的 tool 消息。倒序是关键------splice 会让后面的索引偏移,正序删会跳过元素。
② _backfillMissingToolResults:给「无尾」的调用打补丁
反过来:有 tool_call,但对应的结果丢了(history.jsonl 断尾、中断恢复时常见)。这时候 catbuddy 不是留空,而是补一条占位 result:
php
messages.splice(idx, 0, {
role: 'tool', toolCallId: tc.id, name: tc.name,
content: '[Tool result unavailable --- call was interrupted or lost]',
})
为什么要补而不是留空?因为 tool_call / tool_result 是配对协议 ,模型看到一个调用却没有结果,可能会困惑、幻觉、甚至重复调用。补一条「结果丢了」的明牌,比让它对着残缺对话瞎猜好得多------至少它知道发生了什么。
③ _microcompact:最聪明的一步,纯规则零 LLM
这是我最喜欢的一步。核心洞察:真正吃 token 的,是少数几种工具的旧输出。
ini
const MICROCOMPACT_KEEP_RECENT = 10
const COMPACTABLE_TOOLS = new Set(['read_file','exec','grep','web_search','web_fetch','list_dir'])
逻辑就三句话:扫出所有这些「大体积工具」的结果 → 保留最近 10 条原文 → 更早的统统替换成 [read_file result: 前 80 字符...]。一个原本 5000 token 的 read_file 结果,瞬间瘦成几十 token 的摘要。
为什么不压缩所有工具?因为 write_file、edit_file 的结果通常就一句「文件已写入」,压它没收益。真正的油水在 read_file 读一整个文件、grep 命中 50 行、web_fetch 拉一整页 HTML 上。而且对话越往后,这些详细内容越不重要------模型只要知道「这个文件读过了,大概长这样」就够了。
最关键的是:这一步纯字符串截断,零 LLM 调用。 它发生在每一轮迭代里,要是还得调一次 LLM 去摘要,那开销根本扛不住。确定性、零延迟,这才配得上「每轮都跑」。
④ _snipHistory:最后一道防线,断头
如果前面都做了还是超预算,就只能「断头」------从最早的消息开始删,直到 token 落进预算。预算算法是:
yaml
预算 = 上下文窗口 − 本次最大生成 token − 1024(安全边界)
但断头不是简单地从索引 0 删 N 条。它有两条铁律保护 LLM 协议的合法性:
- 截断点必须落在一条 user 消息上------ChatML 要求 system/user/assistant 交替,从一条 tool 或 assistant 开头会破坏 provider 的角色校验。
- 保证当前的 user 消息(也就是用户刚发的这条问题)绝不被删------从后往前累加 token,最新的消息最重要,永远留住。
javascript
let total = 0
for (let i = messages.length - 1; i >= 0; i--) { // 从后往前累加
total += countMessageTokens(messages[i], encoding)
if (total > budget) {
// 找到截断点,并向后挪到最近的一条 user 消息,保持角色交替
...
messages.splice(0, start) // 从头删到截断点
return
}
}
这一步会真的丢信息,你不会百分百满意它。但它保证 Agent 绝不会因为窗口溢出而崩溃 。它只在 _microcompact 都救不回来时才触发,是名副其实的最后手段。
这四步合起来是什么
把它放回主循环看就清楚了------前几轮上下文很干净,第④步根本不触发;随着对话变长,第③步开始压旧工具输出;还不够就第④步断头;万一历史加载得有问题,第①②步自愈格式。 这是一套渐进式、纯算法、原位自愈的系统:不靠外部干预,每次调 LLM 前自动整理一遍,让 Agent 在超长对话里持续活着。
还有一道更重的防线:COMPACT 让 LLM 自己总结
四步自愈是「轻量级、每轮跑、纯规则」的。但它有个天花板:_microcompact 只会字符串截断 ,不懂语义------一个大文件被砍掉 99% 内容后,万一里面有关键信息,模型可能就丢了。_snipHistory 的断头更是不可逆,切掉的早期上下文永久消失。
所以 catbuddy 还有一道更重的防线兜底:当历史消息数超过阈值(默认 50 条 ),外层状态机(AgentLoop)会进入 COMPACT 状态 ,调一次 LLM ,把旧对话总结成一段结构化摘要:
总结出来的摘要,作为第一条 user 消息 ([Previous conversation summary]: ...)注入下一次的上下文,替换掉那一大坨旧对话。于是:最近 25 条保留原文细节,更早的以「高信息密度的摘要」形式存在。 这本质是一种滚动摘要(rolling summary)------不是粗暴扔掉旧消息,而是把它们压成信息密度更高的形式留下来。
COMPACT 这个状态机本身(它在状态流转里的位置、怎么被触发)第 03 篇讲过,这里只聚焦它触发的那个「摘要压缩」动作。
它和四步自愈是互补,不是替代:
| 四步自愈 _governContext | COMPACT 摘要 | |
|---|---|---|
| 时机 | 每轮调 LLM 前 | 历史超 50 条时 |
| 方式 | 纯算法,零 LLM | 调一次 LLM 总结 |
| 特点 | 在线、同步、确定性 | 离线感、语义压缩 |
| 代价 | 微秒级 | 一次 LLM 调用 |
一个负责「每一轮都不爆」,一个负责「把长历史压成精华」,各司其职。
还有第三层,但那是下一篇的事
讲到这你可能会问:摘要替换了旧对话,可那些被压掉的事实呢?比如「我们第 30 轮定下来用 useReducer」这个决策,下次新开会话,模型还记得吗?
答案是:catbuddy 还有第三层 防线------后台记忆(Dream)。它在 Agent 睡着的时候,把对话里的事实悄悄提炼进 MEMORY.md,下次直接从记忆里读,而不是从历史里翻 。这也正好接上了第 1 节那个「眼睛只从记忆读、不负责写」的伏笔------写记忆的人,就是 Dream。
但这层的细节(怎么提炼、怎么去重、怎么不阻塞对话)全部留给下一篇。这里你只要在脑子里留一个位置:上下文治理是三层------四步自愈(每轮)、COMPACT 摘要(超阈值)、后台记忆(睡着时)------一层比一层重,一层比一层「记得久」。
这篇讲了什么?
- 装配侧 :
ContextBuilder按固定五层(人格 → 引导文件 → 分层记忆 → 技能 → 运行时上下文)拼出每次调 LLM 的视野;两个见功底的细节是指纹缓存 (6-8 个文件的 mtime 当探针,文件一改自动失效重建,热路径零 I/O)和把易变的运行时信息放 user 消息而非系统提示词,避免砸掉缓存。 - 为什么会爆 :token 不按消息条数线性涨,一次
grep/read_file就能吃几千 token;聊到几十轮撞上窗口红线,下次调 LLM 直接context_length_exceeded。治理的前提是用js-tiktoken精确 BPE 计数------比字符÷4 估算准 30%,在大窗口里就是「能答」和「胡说」的差距。 - 治理侧:每轮调 LLM 前跑四步自愈(删孤儿 → 补缺失 → 微压缩 → 断头,纯规则零 LLM);历史超 50 条时 COMPACT 状态调一次 LLM 把旧对话总结成结构化摘要替换掉。一轻一重,互补兜底。
下一篇预告 :眼睛只从 MEMORY.md 读、不负责写------那写记忆的是谁?第 07 篇聊「记忆」:从 JSONL 持久化(为什么不用 SQLite)一路讲到 Dream 后台学习------猫睡着的时候,怎么把对话提炼成长期记忆。