
Agent 记忆管理:上下文管理 + 长期记忆
覆盖:上下文管理完整清单(传统 vs DSH)、决策依据、关键概念(投影/KV缓存/最近尾巴)、mem0、分层架构、路由(agent/模型)、面试追问。 图用 Mermaid;DSH 结论来自其源码包(
dsh-session/dsh-token-meter/dsh-compaction-basic/dsh-compaction-tool-result-pruner/dsh-system-prompt/dsh-output-retention/dsh-subagent/dsh-skill)。
0. 记忆管理的两层结构
记忆管理分两层,按"数据存在哪里"划分:
| 层 | 数据位置 | 生命周期 | 代表技术 |
|---|---|---|---|
| 短期 / 工作记忆 | 模型上下文窗口内 | 单次会话 | token 计量、压缩、裁剪、滑窗、KV 缓存 |
| 长期记忆 | 窗口外的外部存储 | 跨会话 | 提取 + 语义检索 + 更新(mem0、LangGraph Store) |
1. 上下文管理:8 类清单
每类按【要解决的问题 → 传统做法(LangChain/LangGraph)→ DSH 做法】展开。
1.1 上下文窗口(容量)
- 问题:模型单次请求的 token 数有上限(如 128k),所有缩减策略都要基于这个容量做预算。
- 传统:直接往 messages 数组塞,塞爆后收到 provider 报错再手工删减重试。
- DSH :容量不写死,由 LLM adapter 在路由时声明
ctx.llm.resolveModelInfo().context;触发压缩的阈值(thresholdRatio0.8)和压缩后保留的尾巴(retainRatio0.16)是两个独立参数。
1.2 Token 计量
- 问题:需要知道"当前请求多少 token、下一次请求会多少 token"来决定是否缩减。
- 传统 : LangChain:
get_num_tokens()用 tiktoken 同步全量计数;ConversationTokenBufferMemory每次加消息重新计算一遍。 LangGraph:无内建 meter,trim_messages需自传token_counter。 - DSH (
dsh-token-meter):- 固定启发式
4 字符/token + 结构开销,只做参考值,不做计费/门控。 - 增量折叠(replay-aware):折叠会话事件日志维护累加状态,O(surface),不每次全量重数。
- 复用 provider usage:最新请求的"规范信封"与当前一致时,直接用 provider 报的 usage(分 input / cache-read / cache-write / output 四桶),否则退回启发式。
- projectedTokens:估算下一请求的 token 数(压缩是旁路调用不记 usage,纯 provider 报数会滞后,用"锚点 + surface 增减"即时反映)。
- 三种口径:
pressureTokens(provider 报的 prompt 大小)、projectedTokens(下一请求预估)、contextBreakdown(启发式拆成 system/tools/message)。
- 固定启发式
1.3 消息历史存储与派生("删除"语义)+ 投影
- 问题:对话历史怎么存、怎么变成每次发给模型的 messages、以及"删掉某些历史"在数据层怎么表达。
- 传统 :
- LangChain:内存
messages列表;ConversationBufferWindowMemory/TokenBufferMemory直接从数组 drop 旧消息,真删,删了无法回放。 - LangGraph:
state.messages+ checkpointer(MemorySaver/SqliteSaver/PostgresSaver)按 thread 持久化快照;RemoveMessage(id)从 state 移除。
- LangChain:内存
- DSH (
dsh-session):- 事件溯源 :
Session是 append-only 事件日志(唯一事实源),模型消息从日志派生(deriveMessages()投影成 surface)。 - 删除 = replace :追加一条带
surfaceOp: {op:'replace', start, end}的新事件,旧事件仍在日志,只是不再投影。带sourceEventSeqs溯源"这条替换覆盖了哪些原始 seq"。 - 换来:可审计、可确定性回放、可 fork、崩溃可修复。
- 事件溯源 :
投影(projection)与"原值 ↔ 投影"查找:
- 投影 = 把事件流折叠(fold)成的一个"读模型"视图,可由日志确定性重算。DSH 有两处:
dsh-session的 surface(日志 → messages);dsh-session-projection(init/apply/view折叠成 UI 读模型如 tokenUsage、todo、goal)。 - 查找关系:
- 投影 → 原值:消息事件的
sourceEventSeqs记录它引用的原始事件 seq;替换事件的surfaceOp.start/end记录被覆盖的 span。 - 原值 → 投影:append 型消息事件 1:1 映射到自己的 surface 条目;被 replace 覆盖的原始事件已不在 surface 上,靠覆盖它的替换事件(
sourceEventSeqs含该 seq,或surfaceOpspan 覆盖它)反查。
- 投影 → 原值:消息事件的
1.4 压缩总结(compaction)
- 问题:把一大段历史换成一小段摘要,尽量不丢关键信息。
- 传统 :
- LangChain:
ConversationSummaryMemory每轮把"旧摘要 + 新消息"再浓缩;SummaryBufferMemory近 N 条保留原文 + 更旧进摘要。自由文本,不保证变短,不处理工具调用。 - LangGraph:
summarize_conversation预模型钩子,自由文本摘要。
- LangChain:
- DSH (
dsh-compaction-basic+dsh-compaction服务定义):- 触发 :
agent/pre-step压力 ≥ 阈值;或agent/request-error收到溢出;或手动/compact。 - 保留 :只压最老一段,保留最近
retainRatio/retainTokens;切分边界落在工具调用/结果配对完整处。 - 结构化模板:8 章节(Primary Request and Intent / Key Technical Concepts / Files and Code / Errors and Fixes / Pending Jobs / Current Work / Next Step / Critical Context)。
- 可合并:二次压缩时合并旧 checkpoint(保留仍真、删过时、合并新信息),不叠两层摘要。
- 收敛校验 :必须短于原文,否则拒绝;
compactionRetries次仍不达标抛错。 - KV 缓存复用:总结请求把"系统提示 + 工具 + 被压缩区间"逐字节重放为前缀,复用 provider 缓存前缀。
- 只存文本:丢弃 reasoning 和 tool_call(防泄露私有推理、防孤儿工具调用)。
- 事务性 :
compaction/start → summary → 替换 → end,中途崩溃留下可检测的孤儿锁。
- 触发 :
1.5 裁剪 / 截断(truncation / pruning)
- 问题:单个消息(尤其工具结果)太大,截到预算内。不调 LLM,纯字符串操作。
- 传统 :
trim_messages(max_tokens, strategy='last'/'first'),通用、按 token、不区分消息类型、无精确省略元数据。 - DSH :
dsh-compaction-tool-result-pruner:对超预算tool/result做 head/middle/tail(默认 head 4096 + tail 1024 字符,超 8192 触发),写 replace 事件,原始结果保留在日志。dsh-output-retention:ItemRetainer(保前 N 条目)、TextRetainer(head/tail/headTail,按 UTF-8 边界),返回精确省略计数,让工具把"中间省略 N 行"告诉模型。- 裁剪是语法级(保头保尾,不判断中间哪行重要);字符预算 ≠ token 预算,最终由 token-meter 判定。
1.6 滑窗 / 最近尾巴(保留最新 N 条)
- 问题:只保留最近的部分历史,丢弃更旧的。
- 传统 :
ConversationBufferWindowMemory(k=N)只留最近 N 次交互,直接丢更旧的。 - DSH :
retainRatio/retainTokens保留最近尾巴,但更旧的不是丢,而是压缩成摘要(组合式)。 - 最近尾巴 = 压缩时保持原文不动的那段最近消息。必须保留原文,因为最近交换/进行中的工作/未闭合工具调用需要精确状态。
- 注意:纯"保留最新 N"按顺序取舍、不按相关性,早期关键约束会被丢掉------这正是需要长期记忆补位的原因。
1.7 溢出恢复
- 问题:provider 已报"超窗口",不能直接失败,要走恢复路径。
- 传统:手写 try/except + trim 重试。
- DSH :adapter 归一化
CONTEXT_WINDOW_EXCEEDED→agent/request-error→ 绕过阈值强制缩减(prune + 最大平衡头部缩减)→dsh-llm-retry退避重试。
1.8 上下文组装 / 注入 / 隔离(做"加法"和"分流")
- 系统提示词 + 工具 schema 组装 :
dsh-system-prompt按order固定排序 section、{{variable}}插值、显式toolOrder;前缀逐字节稳定是为了 KV 缓存命中;卸载一个工具就省掉整套 schema token。 - 运行时注入 :
dsh-time-context/dsh-tmux-context(变化才注入);dsh-session-reference(跨会话有预算上限的只读快照 + 不可信警告)。 - 隔离 :
dsh-subagent/fork/workflow(子 agent 独立窗口,回传紧凑结果);dsh-skill(技能指令按需加载,非常驻)。
KV 缓存(跨请求 prompt 前缀缓存):
- 推理层 KV cache:生成时缓存已算 token 的 K/V 向量,避免每步重算注意力。
- 跨请求 prompt cache(上下文管理谈的是这个):provider 缓存 prompt 前缀的 K/V;新请求前缀逐字节一致就复用、只算新后缀,命中部分按
cache_read计费(更便宜)。 - 存/查:服务端按前缀哈希存、带 TTL;客户端不能直接查缓存,只能通过 usage 字段(
cache_read_tokens/cache_write_tokens)观察命中。 - 客户端如何配合命中:静态内容放最前且排序固定;历史只 append 不中间插入;压缩时逐字节重放前缀。
2. 决策依据:什么时候做什么
唯一度量源 :ctx.tokenMeter.measure() 的压力值 vs 路由模型 resolveModelInfo().context 容量。
三个触发点 × 动作阶梯:
决策表:
| 触发点 | 条件 | 动作 | 参数 |
|---|---|---|---|
agent/pre-step |
压力 ≥ window × thresholdRatio |
裁剪 → 重测 → 压缩 → 收敛 | thresholdRatio(0.8)、retainRatio(0.16)、compactionRetries(1) |
agent/request-error |
provider 报溢出(归一化 CONTEXT_WINDOW_EXCEEDED) |
绕过阈值强制缩减 + 重试 | maxOverflowRetries(1) |
手动 /compact |
用户显式命令 | 低于阈值也压一段 | --- |
动作阶梯顺序(省成本):先无 LLM 裁剪(零模型调用)→ 重测够了就停 → 不够才调 LLM 压缩(贵)→ 收敛校验 → 溢出走独立强制路径。
注入侧触发总表:
| 处理点 | 何时触发 | 依据 |
|---|---|---|
| system prompt 组装 | 每步 | 恒执行 |
| time / tmux 上下文 | 每步,但状态变化或超 refreshIntervalMs 才注入 |
变化检测 |
| session-reference | 用户 @ 提及会话 | 显式 |
| skill 加载 | 任务匹配技能名 | 按需 |
| subagent / workflow | 显式委派/扇出 | 显式 |
2.2 每个策略的时机(策略 × 触发钩子 × 条件)
| 策略 | 触发时机(生命周期钩子) | 触发条件 | 说明 |
|---|---|---|---|
| Token 计量(更新) | 每次 surface 变化(append/replace 提交后) | 事件驱动,持续维护 | 增量折叠;查询在 pre-step 用 measure() 读 |
| 裁剪(pruner) | 压缩触发后、LLM 压缩前 | 压力 ≥ 阈值 或 溢出恢复 | 只在缩减路径执行;低于阈值的正常步骤从不裁剪 |
| 压缩(compaction) | agent/pre-step;agent/request-error;手动 /compact |
压力 ≥ window×0.8;溢出;用户命令 | 手动可低于阈值 |
| 保留尾巴(retain) | 压缩内部 | 随压缩确定边界 | 不是独立触发,是压缩的参数 |
| 收敛重试 | 压缩内部(压缩后重测) | 仍超阈值 | compactionRetries 次 |
| 溢出恢复 | agent/request-error |
provider 报 CONTEXT_WINDOW_EXCEEDED |
绕过阈值强制缩减 + maxOverflowRetries |
| 重试(llm-retry) | agent/request-error |
错误码 ∈ EMPTY_RESPONSE / RATE_LIMIT / SERVER / TIMEOUT / TRANSPORT | 退避 500ms--10s + jitter |
| 组装(system-prompt) | 每步(请求派生前) | 恒执行 | 前缀固定排序,保证 KV 命中 |
| 注入(time/tmux) | agent/pre-step |
状态变化 或 超 refreshIntervalMs |
变化才注入,避免重复 |
| 注入(session-reference) | 用户 @ 提及会话时 | 显式 | 有字节预算上限 |
| skill 加载 | 任务匹配技能名时 | 按需 | 非常驻 |
| subagent / workflow | 显式委派/扇出时 | 显式 | 独立窗口 |
| KV 缓存(配合) | 持续约束(非触发) | 组装/压缩时保持前缀稳定 | 压缩时重放前缀复用 |
时序关键点(为什么是这个顺序):
- 缩减动作按成本从低到高:先裁剪(无 LLM 调用)→ 重测 → 不够才压缩(调 LLM)。
- 正常路径只在
pre-step做一次压力检查;溢出路径在request-error强制缩减,两者不重叠。 - 计量是"持续维护 + 按需读取":事件驱动更新,决策点才
measure()。
2.3 一次请求的生命周期(各策略挂在哪个钩子)
3. mem0
3.1 定义
mem0 是一个开源的记忆层(memory layer) ,给 LLM 应用/agent 提供独立于上下文窗口的长期记忆。论文:Mem0 (arXiv 2504.19413)。
两阶段流水线:
- 提取:一个 LLM 从对话消息里抽取"值得记的事实/偏好/决策",输出结构化 memory 条目(JSON)。
- 更新 + 存储 :对每条候选记忆,LLM 拿它和已有相关记忆比较,判定 ADD / UPDATE / DELETE / NOOP,写入向量库。
- 检索 :
search(query, user_id, agent_id, run_id)→ query 向量化 → 相似度检索 → 按作用域过滤 → top-k。
存储组件:向量库(Qdrant/Chroma/pgvector/FAISS)、历史库(SQLite/Postgres,记录 add/update/delete 历史)、图库(Neo4j,Graph Memory)、LLM(提取+更新)、Embedder(向量化)。
作用域与类型 (官方 memory-types 文档):
| 维度 | 取值 |
|---|---|
| 作用域 | user(跨会话用户偏好/约束)、agent(agent 策略/行为)、session(run_id) |
| 类型 | episodic(事件)、semantic(事实/偏好)、procedural(怎么做 X 的步骤)、graph(实体-关系) |
3.2 与上下文管理(第 1 章)的逐项区别
| 维度 | 上下文管理(窗口内方案) | mem0 |
|---|---|---|
| 存储位置 | 请求体/进程内存里的 messages | 外部向量库 + 历史库(+图库) |
| 生命周期 | 单次会话 | 跨会话持久化 |
| 数据形态 | 原始消息文本 / 摘要文本 | 结构化 memory 条目(向量 + JSON) |
| 写入方式 | append 然后 replace | LLM 判定 add / update / delete / noop |
| 读取方式 | 按顺序全部送进请求 | 按 query 语义相似度检索 top-k 再注入 |
| 取舍依据 | 时间顺序 / 位置 / 结构边界 | 语义相关性(+图关系遍历) |
| 一致性维护 | 无(原文或摘要,不合并) | 有:去重、纠正、合并(consolidation) |
| 写入是否调 LLM | 压缩调;裁剪/滑窗不调 | 提取和更新都调 |
3.3 新增能力(逐条)
- 外部持久化存储:记忆大小受向量库容量约束,不受窗口约束。
- 自我更新:add/update/delete/noop 判定,会去重、改写错误、合并冲突,长期收敛。
- 按相关性检索而非按顺序保留:解决"早期关键约束被滑窗丢掉"的问题。
- 作用域隔离 :
user_id/agent_id/run_id过滤,多用户多 agent 互不污染。 - 图记忆:实体-关系存 Neo4j,支持关系查询和多跳推理;向量找"相似",图找"关系"。
- 程序性记忆:存"如何完成某任务"的可复用步骤。
- 基础设施抽象:LLM/embedder/向量库/图库可替换,可自托管或托管。
4. 三列总表 + DSH 强弱
| 维度 | LangChain(旧 memory 类) | LangGraph(checkpointer + Store) | DSH |
|---|---|---|---|
| 存储模型 | 内存数组 | state + checkpointer | append-only 事件日志 + 派生 surface |
| 删除 | 真删 | RemoveMessage / checkpoint | replace,日志不删,带溯源 |
| Token 计量 | tiktoken 全量 | 无内建,自传 counter | 增量折叠 + provider usage 校准 + projectedTokens |
| 压缩 | 自由文本摘要,无收敛保证 | summarize_conversation 自由文本 | 结构化 8 章节 + 可合并 + 收敛校验 + 工具配对安全 |
| KV 缓存 | 无 | 无 | 一等约束:前缀稳定 + 重放复用 |
| 裁剪 | trim_messages 通用 | trim_messages 通用 | 专治 tool result + 精确省略计数 |
| 溢出恢复 | 手写 | 手写 | 自动归一化 + 退避重试 |
| 崩溃一致性 | 无 | checkpoint 恢复,无消息级事务 | 压缩锁/孤儿检测 + 工具调用合成恢复 |
| 长期语义记忆 | VectorStore/Entity/KG memory | Store + 语义检索 | 无(短板) |
DSH 强在哪(窗口内/会话内的工程精度):
- 删除 = replace 而非物理删,日志可回放可审计(事件溯源 + surface +
sourceEventSeqs)。 - 增量 + provider 校准的 token 计量(replay-aware 折叠、复用真实 usage 分桶、projectedTokens)。
- KV 缓存是一等约束(前缀逐字节稳定、压缩前缀重放、记录失效点)。
- 压缩可收敛、可合并、事务化(结构化模板、必须短于原文、工具配对安全、start/summary/end + 孤儿锁、崩溃合成恢复)。
- 溢出自动恢复 + provider 级重试。
DSH 弱在哪(面试时主动说) :没有长期语义记忆、没有多用户作用域隔离、没有向量/图检索------这三块是 LangGraph Store + StoreSemanticSearch 和 mem0 的地盘。
5. 分层架构与正确组合
核心结论:mem0 检索出的记忆最终仍作为文本进窗口,所以它不替代 token 计量/压缩/KV 缓存;两层互补,不是二选一。
一句话总结:
LangChain 的记忆是"内存数组 + LLM 摘要";LangGraph 是"checkpointer 持久化 state + Store 跨线程存储";DSH 是"append-only 事件日志 + 派生 surface + 增量 token 计量 + KV 缓存感知的压缩"。 DSH 在单会话内的审计、一致性、缓存、溢出恢复上做到传统框架没有的精度;但没有长期语义记忆层,需要 mem0 或 LangGraph Store 补上。正确架构是分层组合。
6. 面试高频追问 + 参考答案
Q1:投影是什么?原值如何查找到投影? 答:事件溯源里,投影 = 把事件流折叠成的可重算读模型。DSH 的 surface(日志 → messages)和 session-projection(init/apply/view → UI 读模型)都是投影。正向用 sourceEventSeqs(投影条目 → 原始 seq),反向靠 replace 事件的 span 覆盖反查(原值 → 覆盖它的替换事件)。
Q2:KV 缓存是什么?如何做/存/查? 答:分两层。推理层 KV cache 是生成时缓存已算 token 的 K/V 避免重算;跨请求 prompt cache 是 provider 缓存 prompt 前缀 K/V,新请求前缀一致就复用。服务端按前缀哈希存、带 TTL;客户端不能直接查,只能看 usage 字段(cache_read/cache_write)。客户端配合命中靠:静态放前、排序固定、只 append、压缩重放前缀。
Q3:最近尾巴是什么? 答:压缩时保持原文不动的那段最近消息,由 retainRatio(默认 0.16)或 retainTokens 决定;被总结的是"最老 span = 全部 surface − 尾巴 − 未闭合单元"。尾巴必须留原文,因为最近状态需要精确措辞。
Q4:这么多处理点,什么时候做什么的依据? 答:唯一度量源是 tokenMeter.measure() vs 路由模型窗口容量 vs 阈值。三个触发点(pre-step 压力 / request-error 溢出 / 手动 compact),动作按"裁剪(无 LLM)→ 重测 → 压缩(调 LLM)→ 收敛重试"的省钱顺序走,溢出走独立强制路径。
Q5:mem0 和上下文管理(压缩/裁剪/保留N)是什么关系? 答:上下文管理是窗口内有损压缩(单会话、按顺序/结构取舍);mem0 是窗口外长期记忆(跨会话、按相关性检索、可 add/update/delete 收敛)。mem0 检索出的记忆最终仍进窗口,所以不替代压缩/KV 缓存,两层互补。
Q6:为什么要"事件溯源 + replace"而不是直接删消息? 答:直接删无法审计/回放/fork;事件日志 append-only + surface 投影 + replace,旧事件仍在日志、只影响投影,换来确定性重放和崩溃修复,且 sourceEventSeqs 保留溯源。
7. Agent 路由与模型路由
两类路由解决"请求给谁"和"发给哪个模型"两个问题,都发生在请求派生之前,决定后续的窗口预算、缓存与注入面。
7.1 Agent 路由(请求分给哪个 agent/技能/管线)
| 策略 | 做法 | 适用 |
|---|---|---|
| 规则/关键词 | 关键字、正则、前缀命令(如 /compact)命中即分发 |
简单、确定、零成本 |
| LLM 语义路由 | 模型输出结构化分类(function calling / JSON),匹配各 agent 的 routing profile(描述+工具集+权限) | 意图模糊、开放输入 |
| 嵌入语义路由 | 输入与各 agent 描述向量化,相似度 top-k + 阈值 | 不调 LLM、便宜、快 |
| 混合 | 规则/嵌入先走快路径,低于置信阈值再 LLM 兜底;仍低则反问澄清或回退默认 agent | 生产最常用 |
- 每个 agent 必须有 routing description("我能做什么")与 fallback;DSH 的 skill 加载就是"任务匹配技能名"的按需路由。
- 工具:Semantic Router(aurelio-labs)、LangGraph supervisor / handoff、OpenAI Swarm(多 agent 交接)。
7.2 模型路由(请求发给哪个模型)
| 策略 | 做法 |
|---|---|
| 静态映射 | 任务类型 → 模型写死在 adapter,最简单 |
| 能力路由 | 按请求需求查模型能力:上下文容量、工具调用、视觉、推理模式;DSH 在路由时 resolveModelInfo().context 声明容量,token-meter 拿它当预算基准 |
| 级联/升级 | 小模型先行,失败/拒答/低置信升级大模型(Not Diamond 思路) |
| 成本/延迟感知 | 简单任务派小模型、复杂派大模型;偏好有 prompt cache(cache_read 更便宜)的 provider |
| 可用性路由 | 按错误码 failover(rate limit → 换 provider)+ 退避重试;DSH llm-retry 即按错误码分类重试 |
- 工具:LiteLLM(统一网关:model map、fallback、retry、预算)、Portkey(网关 + canary)、OpenRouter(托管路由)、OpenAI Router(开源语义路由)、LangChain
with_fallbacks。
7.3 与上下文管理的关系
- 模型路由决定窗口预算 :
measure()的压力值必须与"被路由到的那个模型"的 context 容量比较;容量不写死、路由时解析,所以thresholdRatio/retainRatio参数不随模型变化。 - 路由决定缓存与计费面:选哪个 provider 决定有没有 prompt cache、cache_read 单价,反过来影响"前缀稳定"值不值得做。
- Agent 路由决定注入面:分给不同 agent = 不同 system prompt + 工具 schema,前缀逐字节稳定以 agent 为单位分别维护。