06|眼睛:LLM 的「视野」怎么拼出来,又怎么不爆

0 阅读18分钟

这是《Agent全栈实战》的第 6 篇。整个系列以 catbuddy(一个本地优先的 AI 编程助手,约 3.6 万行 TypeScript)为案例,由浅入深拆解 harness 设计。前面我们讲了心脏(Agent Loop)和手脚(工具系统)。这一篇聊「眼睛」——模型每次开口之前,它到底看到了什么。你给 LLM 的那一长串 messages,不是随手拼的,而是一条五层流水线装出来的;而对话一长,这串东西就会撑爆上下文窗口,撞上窗口红线直接 context_length_exceeded。所以这篇是一条完整的线:先看怎么拼出来,再看为什么会爆,最后看 catbuddy 怎么治理让它不爆。一篇看完,你能把「上下文工程」从装配到自愈讲给同事听。


  1. 先问一个你可能没细想的问题

你调 LLM 的时候,传进去的是个 messages 数组,对吧?

await llm.chat({ system: "...", messages: [...] })

那个 system 里到底是什么?那个 messages 数组又是谁拼的?

大多数人写 demo 的时候,system 是一句写死的 "You are a helpful assistant"messages 就是把历史 push 进去完事。能跑。但你把它放进一个真正干活、连续聊几百轮的 Agent 里,两个问题立刻冒出来:

  1. 的问题:这个 Agent 的人格、它能用哪些工具、它记得用户什么事、当前在哪个项目——这些信息从哪来,按什么顺序塞进去?拼错了顺序、漏了一块,模型的「视野」就是残缺的。
  2. 的问题:聊到第 200 轮,历史 messages 累计的 token 早就超过窗口了。下一次调 LLM,provider 直接给你甩一个 context_length_exceeded——任务当场崩。

catbuddy 的「眼睛」就是为这两件事生的。它由两半组成:一半负责装配ContextBuilder,把视野拼出来),一半负责治理AgentRunner._governContext,让视野不爆)。我们一个一个看。


  1. 装配侧:ContextBuilder 的五层流水线

先看「拼」。

打开 catbuddy 的日志,你会看到发给 LLM 的 system 大概长这样:

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 来说,这是一道清晰的视觉边界。

image.png

为什么是这个顺序? 因为它遵循「最重要 → 可忽略」的降级原则:先立住「我是谁」,再加「怎么干活」,再补「记得什么」,最后挂上「能用什么」。万一哪天 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内容

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 把技能分两类,注入策略完全不同

  • 始终在线技能memorymy 这两个)——完整 SKILL.md 正文直接塞进系统提示词。因为这两个太核心,每轮都得在场。
  • 按需技能(drawio、github……其他所有)——只放一行摘要- drawio: 生成架构图

为什么按需技能只放摘要不放全文?因为一个技能的全文可能很长(drawio 的全文包含整套画图 DSL 指令)。系统提示词里只需要一份目录,告诉模型「你有这些本事」;真正的全文,等模型实际调用那个技能时再通过 Skill 系统注入。这是典型的「目录常驻、正文懒加载」——省 token 的关键。

⑤ 运行时上下文:工具清单按需注入

最后一层是运行时信息——MCP 工具清单按需注入到这一轮该有的位置。MCP(Model Context Protocol)是接外部工具的标准协议,这块第 05 篇详细讲过,这里不展开。


1.5 装配侧最值得抄的两个工程细节

五层拼法你看懂了,但真正体现「工程功力」的是下面两点。它们解决的都是同一个矛盾:系统提示词好几千 token,每次请求都重拼一遍太亏,但又不能拼一次就永远缓存——文件改了得能感知到。

细节一:指纹缓存——用 mtime 当「内容变没变」的探针

ContextBuilder 不会每次 build() 都重新读文件、跑模板、拼字符串。它先算一个指纹,拿指纹当缓存 key:

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 消息的末尾:

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 消息本来就互不相同,不存在缓存可言,时间放这儿不破坏任何东西。这是一个很小但很见功底的取舍——把「易变的东西」和「可缓存的东西」分开放。


  1. 为什么会爆:token 撞上窗口红线

视野拼好了,现在看「爆」。

模型的上下文窗口是有上限的——常见的 128K,大的 200K。听起来很大?我们算笔账:

一条用户消息              ~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 撞上窗口红线:

image.png

这就是所有 AI 助手都躲不掉的坎。你有没有对着某个聊天机器人聊了俩小时,它突然开始胡说八道?不是模型变笨了,是窗口满了、开头被截断了,它丢了「我们之前聊到哪」的记忆——而界面上还显示着完整记录,你根本不知道它已经看不到开头了。

catbuddy 的解法不是粗暴地「从头删」,而是一套先精确测量、再分级治理的机制。


  1. 治理侧之一:先把 token 数得准

要治理,先得知道「现在到底用了多少 token」。这一步不能靠估算

最常见的偷懒办法是 text.length / 4。但这个估算误差大到离谱,因为中英文、代码的 token 密度天差地别:

"Hello world"        → 2 tokens   (英文约 4 字符/token)
"你好世界"            → 8 tokens   (中文约 1.5 字符/token)
"print('hello')"     → ~5 tokens  (代码更密)

catbuddy 用 js-tiktokentoken-counter.ts)——OpenAI 那套 BPE 分词器的纯 JS 移植。BPE(Byte Pair Encoding,字节对编码)的思路是:从字节开始,反复合并最高频的相邻对,形成子词词表。"unbelievable" 可能被切成 ["un", "believ", "able"],每个汉字往往单独成一个 token。这是 GPT 系列真正在用的分词方式,所以数出来是精确值,不是猜。

数一条消息的 token,遵循 OpenAI Cookbook 的规范——content、tool_calls 的参数、name 字段都要算,再加每条消息固定的格式开销:

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 甩错误回来才慌。


  1. 治理侧之二:每轮调 LLM 前的四步自愈

数准了,接下来是真正的治理。

catbuddy 在 AgentRunner.run() 的主循环里,每一轮调 LLM 之前,都先跑一遍 _governContext()——一条四步流水线,纯规则、零 LLM 调用、微秒级完成。它把那个会越长越脏的 messages 数组,原地整理到「干净、合法、不超预算」:

private _governContext(messages: LLMMessage[], spec: RunSpec) {
  this._dropOrphanToolResults(messages)       // ① 删孤儿
  this._backfillMissingToolResults(messages)  // ② 补缺失
  this._microcompact(messages)                // ③ 微压缩
  this._snipHistory(messages, spec)           // ④ 断头截断
}

这个顺序不是随意的:清理 → 修复 → 压缩 → 截断,每一步给下一步准备好干净的数据。

image.png

① _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

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 的,是少数几种工具的旧输出。

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_fileedit_file 的结果通常就一句「文件已写入」,压它没收益。真正的油水在 read_file 读一整个文件、grep 命中 50 行、web_fetch 拉一整页 HTML 上。而且对话越往后,这些详细内容越不重要——模型只要知道「这个文件读过了,大概长这样」就够了。

最关键的是:这一步纯字符串截断,零 LLM 调用。 它发生在每一轮迭代里,要是还得调一次 LLM 去摘要,那开销根本扛不住。确定性、零延迟,这才配得上「每轮都跑」。

④ _snipHistory:最后一道防线,断头

如果前面都做了还是超预算,就只能「断头」——从最早的消息开始删,直到 token 落进预算。预算算法是:

预算 = 上下文窗口  本次最大生成 token  1024(安全边界)

但断头不是简单地从索引 0 删 N 条。它有两条铁律保护 LLM 协议的合法性:

  1. 截断点必须落在一条 user 消息上——ChatML 要求 system/user/assistant 交替,从一条 tool 或 assistant 开头会破坏 provider 的角色校验。
  2. 保证当前的 user 消息(也就是用户刚发的这条问题)绝不被删——从后往前累加 token,最新的消息最重要,永远留住。
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 在超长对话里持续活着。


  1. 还有一道更重的防线:COMPACT 让 LLM 自己总结

四步自愈是「轻量级、每轮跑、纯规则」的。但它有个天花板:_microcompact 只会字符串截断,不懂语义——一个大文件被砍掉 99% 内容后,万一里面有关键信息,模型可能就丢了。_snipHistory 的断头更是不可逆,切掉的早期上下文永久消失。

所以 catbuddy 还有一道更重的防线兜底:当历史消息数超过阈值(默认 50 条),外层状态机(AgentLoop)会进入 COMPACT 状态调一次 LLM,把旧对话总结成一段结构化摘要

image.png 总结出来的摘要,作为第一条 user 消息[Previous conversation summary]: ...)注入下一次的上下文,替换掉那一大坨旧对话。于是:最近 25 条保留原文细节,更早的以「高信息密度的摘要」形式存在。 这本质是一种滚动摘要(rolling summary)——不是粗暴扔掉旧消息,而是把它们压成信息密度更高的形式留下来。

COMPACT 这个状态机本身(它在状态流转里的位置、怎么被触发)第 03 篇讲过,这里只聚焦它触发的那个「摘要压缩」动作。

它和四步自愈是互补,不是替代:

四步自愈 _governContextCOMPACT 摘要
时机每轮调 LLM 前历史超 50 条时
方式纯算法,零 LLM调一次 LLM 总结
特点在线、同步、确定性离线感、语义压缩
代价微秒级一次 LLM 调用

一个负责「每一轮都不爆」,一个负责「把长历史压成精华」,各司其职。


  1. 还有第三层,但那是下一篇的事

讲到这你可能会问:摘要替换了旧对话,可那些被压掉的事实呢?比如「我们第 30 轮定下来用 useReducer」这个决策,下次新开会话,模型还记得吗?

答案是:catbuddy 还有第三层防线——后台记忆(Dream)。它在 Agent 睡着的时候,把对话里的事实悄悄提炼进 MEMORY.md,下次直接从记忆里读,而不是从历史里翻。这也正好接上了第 1 节那个「眼睛只从记忆读、不负责写」的伏笔——写记忆的人,就是 Dream。

但这层的细节(怎么提炼、怎么去重、怎么不阻塞对话)全部留给下一篇。这里你只要在脑子里留一个位置:上下文治理是三层——四步自愈(每轮)、COMPACT 摘要(超阈值)、后台记忆(睡着时)——一层比一层重,一层比一层「记得久」。


这篇讲了什么?

  1. 装配侧ContextBuilder 按固定五层(人格 → 引导文件 → 分层记忆 → 技能 → 运行时上下文)拼出每次调 LLM 的视野;两个见功底的细节是指纹缓存(6-8 个文件的 mtime 当探针,文件一改自动失效重建,热路径零 I/O)和把易变的运行时信息放 user 消息而非系统提示词,避免砸掉缓存。
  2. 为什么会爆:token 不按消息条数线性涨,一次 grep/read_file 就能吃几千 token;聊到几十轮撞上窗口红线,下次调 LLM 直接 context_length_exceeded。治理的前提是用 js-tiktoken 精确 BPE 计数——比字符÷4 估算准 30%,在大窗口里就是「能答」和「胡说」的差距。
  3. 治理侧:每轮调 LLM 前跑四步自愈(删孤儿 → 补缺失 → 微压缩 → 断头,纯规则零 LLM);历史超 50 条时 COMPACT 状态调一次 LLM 把旧对话总结成结构化摘要替换掉。一轻一重,互补兜底。

下一篇预告:眼睛只从 MEMORY.md 读、不负责写——那写记忆的是谁?第 07 篇聊「记忆」:从 JSONL 持久化(为什么不用 SQLite)一路讲到 Dream 后台学习——猫睡着的时候,怎么把对话提炼成长期记忆。