吃透 AI Agent 开发 · 第 23 篇 · 第四章 · Context Engineering
聊天记录、会话状态、项目记忆和用户偏好生命周期不同。先分层,再决定文件还是数据库。
你昨天用模型 A 和 Agent 讨论了一个项目,今天改用模型 B,恢复昨天的会话继续工作。结果系统读取历史记录后,又悄悄切回模型 A。
从“还原现场”的角度看,这似乎合理;从用户角度看却很荒唐:我刚刚明确选择的模型为什么不生效?根源在于系统没有分清会话历史和当前运行配置。
先画四个抽屉
Agent 常见状态可以放进四个抽屉:
- 单步状态:当前请求、工具调用和取消信号。
- 会话状态:消息历史、压缩摘要、用量记录。
- 长期记忆:用户明确希望跨会话保留的信息。
- 运行配置:当前模型、Endpoint、权限模式和本进程覆盖值。
恢复会话应该打开第二个抽屉,不应顺手覆盖第四个。长期记忆也不是把所有会话复制一遍,而是经过筛选的少量稳定信息。
会话适合做事件日志
本地 Agent 可以用 append-only JSONL 保存会话:一行一条事件,持续追加。
{"type":"user","text":"检查登录失败"}
{"type":"assistant","parts":[{"type":"tool-call","name":"grep"}]}
{"type":"tool","name":"grep","summary":"找到 3 处"}
{"type":"usage","inputTokens":3200,"outputTokens":280}
它的优点很朴素:写入时不必重写整个大文件;进程中断通常只损坏最后一行;调试时可以按顺序看到发生过什么。
如果使用数据库,也可以保留同样的事件思想。关键不是 JSONL,而是不要每轮只覆盖一份“当前状态”,导致工具轨迹和历史决定无法追查。
type TranscriptEntry =
| { type: 'user'; text: string; at: string }
| { type: 'assistant'; parts: MessagePart[]; at: string }
| { type: 'tool'; name: string; summary: string; at: string }
| { type: 'usage'; inputTokens: number; outputTokens: number; at: string }
| { type: 'compaction'; summary: string; at: string }
恢复上下文,不恢复控制权
历史 metadata 可以记录“当时使用模型 A”,用于展示、成本统计和排障。但新的模型请求应该使用当前运行时的 effective model。
同样的边界还适用于:
- 历史 Endpoint 不应覆盖当前配置。
- 历史权限模式不应自动授予今天的能力。
- 历史工作目录不存在时,应提示而不是盲目进入。
- 历史工具结果可以恢复摘要,不应重新执行副作用。
如果历史模型与当前模型不同,可以提示一次“本会话之前使用模型 A,本次将使用模型 B”,但不需要阻止用户。
输入历史不是会话历史
终端的上下键历史用于快速找回输入,生命周期和内容都不同。它可能跨会话,甚至跨项目;也更容易意外保存密钥、命令和私人路径。
输入历史至少应该过滤:空白、连续重复、以空格开头的敏感输入和常见密钥模式。高隐私场景可以只保存 hash 或完全关闭。
不要因为它也叫“history”,就把它塞进会话 transcript。
什么时候一条信息才算记忆
“记住我默认使用 pnpm”是长期偏好。“刚才测试失败”是当前任务事实。把后者写进长期记忆,几天后它就会变成过期噪声。
稳妥的记忆策略可以很保守:
- 只保存用户明确要求记住的长期信息。
- 带上来源、创建时间和更新时间。
- 按主题拆分,入口文件只做短索引。
- 检索时先看标题和描述,再按预算加载正文。
- 注入旧记忆时提示年龄,并要求结合当前证据验证。
记忆有了时间,系统才有机会区分“偏好”和“陈年现场记录”。
会话损坏怎么办
Append-only 文件可以逐行解析。遇到一行损坏时,记录行号并跳过,而不是让整段会话无法恢复。但容错也不能静默吞掉关键状态:如果损坏行是用户消息或压缩摘要,界面应提示恢复不完整。
写 metadata、索引和重命名信息时,优先使用原子写:先写临时文件,再替换目标。这样进程在写到一半时不容易留下截断 JSON。
删除最好先进入回收站
会话删除不是清空一个内存数组,而是用户数据操作。默认移动到 trash,支持恢复;真正 purge 时再明确范围和数量。路径计算应复用统一的存储 helper,避免一处使用项目目录,另一处使用用户目录,最后删错位置。
用 Expiry + Validation 做个小练习
实现一个会话恢复函数,输入三行 JSONL,其中第二行故意损坏:
function recover(lines: string[]) {
const entries = []
const errors = []
for (const [index, line] of lines.entries()) {
try {
entries.push(JSON.parse(line))
} catch {
errors.push(index + 1)
}
}
return { entries, errors }
}
然后给返回结果增加 incomplete: boolean,并决定界面何时应该阻止继续执行,何时只提示警告。
Turn State 到 Expiry + Validation 的小结
会话保存过去发生的事情,记忆保存少量跨会话信息,运行配置决定现在怎样执行。三个边界分开后,恢复、切换和隐私策略才不会互相踩脚。
下一篇把视线移到本地文件:怎样让 Agent 读取附件、修改代码并支持回滚,同时不突破工作区边界?
一条消息会经过三种生命周期
用户说“这个项目以后都用 pnpm”,当前轮次需要立刻遵守,会话恢复时也应看到;是否进入长期项目记忆,则取决于用户是否明确要求保存。相反,“当前 Git 有三个修改文件”只属于这一轮,写进 Memory 很快就会过期。
flowchart TB
Input[用户输入] --> Turn[本轮状态]
Turn --> Transcript[会话记录]
Turn --> Candidate[记忆候选]
Candidate --> Confirm{明确要求保存?}
Confirm -- 否 --> Expire[随任务结束]
Confirm -- 是 --> Memory[长期记忆]
Memory --> Select[按相关性和年龄选择]
Select --> Next[未来请求]
三种存储不要共享同一条真相
| 存储 | 主要内容 | 不应该保存 |
|---|---|---|
| Turn state | 当前输入、附件、临时状态 | 长期偏好 |
| Transcript | 已发生的消息与工具元数据 | 图片正文和敏感工具原文 |
| Memory | 明确保存的稳定信息 | Git 状态、临时计划、模型猜测 |
q-code 的生命周期路径
| 阅读编号 | 源码路径 | 检查内容 |
|---|---|---|
| 1. 会话存储 | src/session/store.ts |
append-only 消息和 metadata 如何分开 |
| 2. 模型边界 | src/session/model-boundary.ts |
恢复历史为何不能恢复旧模型选择 |
| 3. 记忆目录 | src/context/memory/memdir.ts |
索引与主题文件怎样维护 |
| 4. 记忆选择 | src/context/memory/selection.ts |
headers、正文预算和年龄如何注入 |
失败反例:把所有对话自动写成记忆
把所有对话自动写成记忆,导致临时猜测、隐私和旧约束混在一起。之后每轮都注入更多“历史事实”,模型看似更懂用户,实际越来越难区分当前要求和旧背景。
修复时默认关闭自动沉淀,只保存用户明确要求记住的长期信息。写入维护创建和更新时间;读取携带年龄与验证提示;用户说“忽略记忆”时,索引和正文都不得进入请求。
练习:给十条信息分配归宿
把十条真实信息分到本轮、会话、记忆或不保存四类,并为每条写删除条件。然后恢复会话并切换模型,确认历史上下文仍在、当前模型不被覆盖;再发一次“忽略记忆”,确认请求中没有记忆正文。
Session Metadata 是目录,不是执行配置
metadata 可以保存标题、创建时间、最后活动时间、当时模型、消息数、用量和项目键,帮助 /sessions 列表与搜索。恢复时它指向 transcript,却不拥有当前 endpoint、API key 或权限。
历史模型仍有价值:成本报表知道当时用什么,界面在模型变化时提示一次,排障能解释 provider 私有 part。它是描述过去的字段,不是下次请求的 setter。
这条规则要覆盖所有恢复入口:--continue、指定 session、TUI 切换和输入历史召回。只修一个命令,另一个入口仍可能把旧模型带回来。
项目键要稳定,也不能泄露绝对路径
会话按项目分组时,可以从规范化 cwd 生成 projectKey。Windows 盘符大小写、分隔符与 symlink 需要统一,否则同一项目会出现多个目录。界面只显示项目名或脱敏摘要,不把本机绝对路径上传到观测平台。
项目移动后,旧 projectKey 可能不再匹配。可以提供显式迁移或链接映射,不能扫描全盘猜测;/sessions 默认只展示当前项目记录,避免用户在共享屏幕时看到其他私人项目。
存储路径计算集中在 helper。删除、导出、artifact 和 file-history 都使用同一 projectKey 规则,避免一处写 home、一处写项目目录。
Append-only 也需要完整性与并发策略
两条请求同时 append 同一 JSONL,若没有串行队列,长行可能交错。单进程可按 session 建写入队列,多进程需要文件锁或明确不支持并发写;metadata 通过临时文件加原子 replace 更新。
每条事件带 eventId、schemaVersion 和可选 previous hash,读取时能发现重复、顺序跳跃和篡改。普通个人会话未必需要完整哈希链,但至少要检测截断尾行并报告 incomplete。
工具正文、图片 base64 和 Shell 全文不进 transcript。只保存协议所需消息、脱敏摘要和 artifact 引用,既控制体积,也降低会话文件泄露风险。
会话搜索和模型上下文不是一回事
用户搜索“上次讨论缓存”可以在所有 transcript 的脱敏文本索引中找候选;选中会话后,恢复器再按压缩记录和预算构建模型上下文。搜索命中不代表全文要一次注入。
索引可以从会话原件重建,不做唯一存储。删除或移入 trash 后立即从活跃索引撤下;恢复时再加回。高隐私模式可关闭全文索引,只按标题、日期和用户自定义标签查找。
Memory 写入要保留 createdAt 和 updatedAt
第一次保存“本项目使用 pnpm”写 createdAt;规则变化时更新正文与 updatedAt,createdAt 不应重置。lastAccessedAt 只说明最近被选择,不说明内容被验证。
主题文件头可以很短:name、description、type、source、createdAt、updatedAt、status。MEMORY.md 只列 headers 与链接,选择器先看索引,再为当前 query 挑少量正文。
若写入时索引更新失败,不能出现主题文件存在但永远不可发现的静默状态。使用临时文件与原子替换,或在启动时扫描重建索引并报告不一致。
注入预算要跨轮累计
单轮每个记忆文件 4KB、总共 20KB 看似有限,长会话每轮重复注入仍会消耗很多。可以设会话累计预算,并对已验证、近期使用的条目只注入短提醒或引用;任务真正需要时再读正文。
选择只使用 headers 与 userQuery,避免为了决定相关性先把所有正文塞给模型。正文进入 transient context,不写回历史,防止下一轮既从 transcript 又从 Memory 重复看到。
用户说“忽略记忆”时,连索引摘要也不注入。不能只隐藏正文,却告诉模型有哪些记忆标题。
隐私模式要覆盖输入历史、会话和记忆
关闭 Memory 自动提取,不代表输入历史没有保存密钥;会话 redact,也不代表 Shell artifact 没有原文。用户需要理解各存储层及开关。
默认过滤常见 secret pattern、空格开头输入和连续重复;高隐私模式可以只存 hash 或完全关闭 history。会话导出前再次脱敏,并列出不包含的 artifact;长期记忆只保存用户明确要求的稳定信息。
数据在本地也需要文件权限与清理策略。本地优先不是无需安全,而是把控制权留给用户。
Trash、Restore、Purge 是三个动作
删除会话先移动到 trash,记录原 sessionId 和删除时间;restore 检查目标是否冲突;purge 才真正删除正文和相关索引。artifact、file-history 是否一起清理要明确提示数量和可恢复性。
会话导出则生成一个版本化包,包含 transcript、metadata 和可选 artifact manifest,不默认打包秘密原文。导入时校验 schema 与路径,不能让归档中的相对路径覆盖包外文件。
恢复演练要包含模型切换和损坏尾行
创建会话,用模型 A 产生工具调用,尾行写一半模拟崩溃;新进程选择模型 B 后恢复。期望是:有效事件被恢复,损坏行有提示,旧模型只用于展示,新请求使用 B,历史工具不重跑,当前权限重新计算。
再把一条项目记忆设为 stale、一条设为 verified,发“忽略记忆”。请求 manifest 中两条都应缺席。这样的演练比单独测 JSON.parse 更接近真实生命周期。