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

这是《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 里,两个问题立刻冒出来:

  1. 的问题:这个 Agent 的人格、它能用哪些工具、它记得用户什么事、当前在哪个项目------这些信息从哪来,按什么顺序塞进去?拼错了顺序、漏了一块,模型的「视野」就是残缺的。
  2. 的问题:聊到第 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 把技能分两类,注入策略完全不同

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

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

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

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

yaml 复制代码
预算 = 上下文窗口 − 本次最大生成 token − 1024(安全边界)

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

  1. 截断点必须落在一条 user 消息上------ChatML 要求 system/user/assistant 交替,从一条 tool 或 assistant 开头会破坏 provider 的角色校验。
  2. 保证当前的 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 摘要(超阈值)、后台记忆(睡着时)------一层比一层重,一层比一层「记得久」。


这篇讲了什么?

  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 后台学习------猫睡着的时候,怎么把对话提炼成长期记忆。

相关推荐
浪遏1 小时前
09|不改核心代码,只插 Hook:Agent 生命周期扩展
ai编程
浪遏1 小时前
04|手脚①:工具的注册、调度与文件安全边界
ai编程
浪遏1 小时前
07|记忆:从 JSONL 持久化到 Dream 后台学习
ai编程
浪遏7 小时前
08|韧性:一套接口接三家 API —— LLMProvider
ai编程
chaors7 小时前
DeepResearchSystem 0x06:LLM as Judge
llm·agent·ai编程
浪遏8 小时前
02|50 行跑通一个 Agent Loop:harness 的最小内核
ai编程
小虎AI生活9 小时前
workbuddy 获客自动化,每天让浏览器 Agent 帮你巡场
ai编程
wechatbot88814 小时前
企业微信API开发:登录-联系人查询-消息发送完整开发流程分享
汇编·微信·自动化·企业微信·ai编程·rpa
AI大模型-小华14 小时前
Codex 反复重试仍完不成任务?判断 ChatGPT Plus 是否需要调整到 Pro
人工智能·chatgpt·ai编程·codex·开发效率·chatgpt plus·chatgpt pro