中文资讯 掘金 AI Sat, 22 Aug 2026 正文 ok 1882 words

DSH harness 中的上下文管理探秘

英文标题:DSH harness 中的上下文管理探秘

Agent 记忆管理:上下文管理 + 长期记忆 0. 记忆管理的两层结构 记忆管理分两层,按"数据存在哪里"划分: 层 数据位置 生命周期 代表技术 短期 / 工作记忆 模型上下文窗口内 单次会话 t
打开原文相关 3 · 机会 1
media3.2 KB media589 B media52.1 KB media5.1 KB

中文

image.png

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;触发压缩的阈值(thresholdRatio 0.8)和压缩后保留的尾巴(retainRatio 0.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)。

    flowchart LR EV["会话事件日志"] --> FOLD["surface-fold 增量折叠O(surface)"] FOLD --> H["启发式 4字符/token + 结构开销"] USAGE["provider 报的 usageinput/cache-read/cache-write/output"] --> REUSE{"最新信封与当前一致?"} REUSE -- "是" --> H2["复用 provider 数值为锚点"] REUSE -- "否" --> H H2 --> PROJ["projectedTokens = 锚点 + surface 增减"] PROJ --> OUT["contextBreakdownsystem/tools/message 拆解"]

1.3 消息历史存储与派生("删除"语义)+ 投影

  • 问题 :对话历史怎么存、怎么变成每次发给模型的 messages、以及"删掉某些历史"在数据层怎么表达。

  • 传统 :

    • LangChain:内存 messages 列表;ConversationBufferWindowMemory / TokenBufferMemory 直接从数组 drop 旧消息,真删,删了无法回放。
    • LangGraph:state.messages + checkpointer(MemorySaver/SqliteSaver/PostgresSaver)按 thread 持久化快照;RemoveMessage(id) 从 state 移除。
  • DSH (dsh-session):

    • 事件溯源 :Session 是 append-only 事件日志(唯一事实源),模型消息从日志派生(deriveMessages() 投影成 surface)。
    • 删除 = replace :追加一条带 surfaceOp: {op:'replace', start, end} 的新事件,旧事件仍在日志,只是不再投影。带 sourceEventSeqs 溯源"这条替换覆盖了哪些原始 seq"。
    • 换来:可审计、可确定性回放、可 fork、崩溃可修复。

    flowchart TB subgraph LC["LangChain(旧)"] A1["内存 messages 数组"] --> A2["直接增删,删了无法回放"] end subgraph LG["LangGraph"] B1["state.messages"] --> B2["checkpointer 按 thread 持久化快照"] end subgraph DSH["DSH"] C1["Session:append-only 事件日志(唯一事实源)"] --> C2["deriveMessages() 派生"] C2 --> C3["surface:模型可见消息"] C3 --> C4["replace 覆盖旧 span,日志永不删"] end

投影(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,或 surfaceOp span 覆盖它)反查。

    flowchart TB subgraph LOG["日志(原值)"] E1["seq=1 user/message"] E2["seq=2 assistant/message"] E3["seq=3 tool/result"] E4["seq=4 user/messagesurfaceOp: replace 1-3sourceEventSeqs: [1,2,3]"] end subgraph SURF["surface(投影)"] S1["summary 消息(由 E4 投影)"] end E4 -->|"正向:sourceEventSeqs=[1,2,3]"| S1 E1 -.->|"反向:被 E4 的 span 覆盖"| E4

1.4 压缩总结(compaction)

  • 问题 :把一大段历史换成一小段摘要,尽量不丢关键信息。

  • 传统 :

    • LangChain:ConversationSummaryMemory 每轮把"旧摘要 + 新消息"再浓缩;SummaryBufferMemory 近 N 条保留原文 + 更旧进摘要。自由文本,不保证变短,不处理工具调用。
    • LangGraph:summarize_conversation 预模型钩子,自由文本摘要。
  • 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,中途崩溃留下可检测的孤儿锁。

    sequenceDiagram participant Agent as agent-loop participant C as compaction-basic participant LLM as llm.stream participant S as Session 日志

    Agent->>C: pre-step 检查压力
    C->>S: append compaction/start(加锁)
    C->>LLM: 逐字节重放系统提示+工具+被压缩区间作前缀
    LLM-->>C: 结构化 8 章节摘要
    C->>C: 校验:必须短于原文,否则拒绝+重试
    C->>S: append compaction/summary
    C->>S: append user/message(replace)
    C->>S: append compaction/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 判定。

    flowchart LR T["超预算 tool/result"] --> P["dsh-compaction-tool-result-pruner"] P --> R["保留 head 4096 + tail 1024 字符中间 [... tool result middle pruned ...]"] R --> W["写入 replace 事件,原始结果保留在日志"] T2["超预算文本/条目"] --> O["dsh-output-retention"] O --> O2["ItemRetainer 保前 N 条目TextRetainer head/tail/headTail"] O2 --> O3["返回精确省略计数告诉模型"]

1.6 滑窗 / 最近尾巴(保留最新 N 条)

  • 问题 :只保留最近的部分历史,丢弃更旧的。

  • 传统 :ConversationBufferWindowMemory(k=N) 只留最近 N 次交互,直接丢更旧的 。

  • DSH :retainRatio/retainTokens 保留最近尾巴,但更旧的不是丢,而是压缩成摘要 (组合式)。

  • 最近尾巴 = 压缩时保持原文不动的那段最近消息。必须保留原文,因为最近交换/进行中的工作/未闭合工具调用需要精确状态。

  • 注意 :纯"保留最新 N"按顺序取舍、不按相关性,早期关键约束会被丢掉——这正是需要长期记忆补位的原因。

    flowchart LR OLD["最老 span → 总结成摘要"] -->|replace| SUM["compacted-summary 摘要"] TAIL["最近尾巴 → 保持原文"] --> TAIL2["最近的若干条消息(原文)"]

1.7 溢出恢复

  • 问题 :provider 已报"超窗口",不能直接失败,要走恢复路径。

  • 传统 :手写 try/except + trim 重试。

  • DSH :adapter 归一化 CONTEXT_WINDOW_EXCEEDED → agent/request-error → 绕过阈值强制缩减(prune + 最大平衡头部缩减)→ dsh-llm-retry 退避重试。

    flowchart LR P["provider 报 context_length_exceeded"] --> N["adapter 归一化 CONTEXT_WINDOW_EXCEEDED"] N --> R["agent/request-error 瀑布"] R --> S["裁剪 → 最大平衡头部缩减"] S --> RT["llm-retry 授权重试,指数退避+jitter"] RT --> P

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(技能指令按需加载,非常驻)。

    flowchart TB subgraph ADD["注入(加法)"] A1["dsh-system-prompt:section 固定排序 + 变量插值 + toolOrder"] A2["dsh-time-context / dsh-tmux-context:变化才注入"] A3["dsh-session-reference:跨会话有预算上限的只读快照"] end subgraph ISO["隔离(分流)"] B1["dsh-subagent / fork / workflow:子 agent 独立窗口"] B2["dsh-skill:技能指令按需加载"] end

KV 缓存(跨请求 prompt 前缀缓存) :

  • 推理层 KV cache:生成时缓存已算 token 的 K/V 向量,避免每步重算注意力。

  • 跨请求 prompt cache(上下文管理谈的是这个):provider 缓存 prompt 前缀的 K/V;新请求前缀逐字节一致就复用、只算新后缀,命中部分按 cache_read 计费(更便宜)。

  • 存/查:服务端按前缀哈希存、带 TTL;客户端不能直接查缓存,只能通过 usage 字段(cache_read_tokens/cache_write_tokens)观察命中。

  • 客户端如何配合命中:静态内容放最前且排序固定;历史只 append 不中间插入;压缩时逐字节重放前缀。

    flowchart LR A["静态内容放最前 system prompt + 工具 schema"] --> B["排序固定、逐字节稳定"] B --> C["历史只 append,不在中间插入/修改"] C --> D["压缩时逐字节重放前缀"] D --> E["新内容只出现在末尾"]


2. 决策依据:什么时候做什么

唯一度量源 :ctx.tokenMeter.measure() 的压力值 vs 路由模型 resolveModelInfo().context 容量。

三个触发点 × 动作阶梯 :

flowchart TB
    P["pre-step 发请求前"] --> M["measure() 测压力"]
    M --> T{"压力 ≥ window × thresholdRatio?"}
    T -- "否" --> GO["正常发请求"]
    T -- "是" --> PR["① pruner 裁剪超预算 tool result"]
    PR --> M2["② 重测"]
    M2 --> T2{"仍超?"}
    T2 -- "否" --> GO
    T2 -- "是" --> SUM["③ LLM 压缩,压最老 span 留尾巴"]
    SUM --> M3["④ 重测,超则 compactionRetries 重试"]
    M3 --> GO
    GO --> ERR{"provider 报溢出?"}
    ERR -- "是" --> REC["绕过阈值:prune + 最大平衡头部缩减 + 重试"]
    REC --> GO

决策表 :

触发点 条件 动作 参数
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 缓存(配合) 持续约束(非触发) 组装/压缩时保持前缀稳定 压缩时重放前缀复用

时序关键点(为什么是这个顺序) :

  1. 缩减动作按成本从低到高:先裁剪(无 LLM 调用)→ 重测 → 不够才压缩(调 LLM) 。
  2. 正常路径只在 pre-step 做一次压力检查;溢出路径在 request-error 强制缩减,两者不重叠。
  3. 计量是"持续维护 + 按需读取":事件驱动更新,决策点才 measure()。

2.3 一次请求的生命周期(各策略挂在哪个钩子)

sequenceDiagram
    participant User as 用户输入
    participant Pre as agent/pre-step
    participant Derive as 请求派生
    participant LLM as llm.stream
    participant Err as agent/request-error
    participant Log as Session 日志

    User->>Pre: 进入 inbox
    Pre->>Pre: 组装 system-prompt(每步)
    Pre->>Pre: time/tmux 注入(变化才注入)
    Pre->>Pre: measure() 压力检查
    Pre->>Pre: 超阈值则 pruner 裁剪 → 重测 → 压缩
    Pre->>Derive: 请求派生(deriveMessages + header)
    Derive->>LLM: 发送请求
    LLM-->>Log: 成功:记录 assistant + usage(token-meter 增量)
    LLM-->>Err: 失败
    Err->>Err: 溢出则溢出恢复;其他则 llm-retry 退避重试
    Err->>Derive: 重试(同一历史)

3. mem0

3.1 定义

mem0 是一个开源的记忆层(memory layer) ,给 LLM 应用/agent 提供独立于上下文窗口的长期记忆。论文:Mem0 (arXiv 2504.19413)。

两阶段流水线 :

  1. 提取 :一个 LLM 从对话消息里抽取"值得记的事实/偏好/决策",输出结构化 memory 条目(JSON)。
  2. 更新 + 存储 :对每条候选记忆,LLM 拿它和已有相关记忆比较,判定 ADD / UPDATE / DELETE / NOOP ,写入向量库。
  3. 检索 :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(向量化)。

flowchart LR
    M["对话消息"] --> E["LLM 提取事实/偏好/决策"]
    E --> C["候选记忆条目 JSON"]
    C --> U["LLM 判定 ADD/UPDATE/DELETE/NOOP"]
    U --> V[("向量库 Qdrant/Chroma/pgvector")]
    U --> H[("历史库 SQLite/Postgres")]
    U --> G[("图库 Neo4j / Graph Memory")]
    Q["查询 query + user_id/agent_id"] --> EM["embedder 向量化"]
    EM --> SR["相似度检索 + scope 过滤"]
    V --> SR
    SR --> K["top-k 相关记忆"]
    K --> I["作为文本注入上下文"]

作用域与类型 (官方 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 新增能力(逐条)

  1. 外部持久化存储 :记忆大小受向量库容量约束,不受窗口约束。
  2. 自我更新 :add/update/delete/noop 判定,会去重、改写错误、合并冲突,长期收敛。
  3. 按相关性检索而非按顺序保留 :解决"早期关键约束被滑窗丢掉"的问题。
  4. 作用域隔离 :user_id/agent_id/run_id 过滤,多用户多 agent 互不污染。
  5. 图记忆 :实体-关系存 Neo4j,支持关系查询和多跳推理;向量找"相似",图找"关系"。
  6. 程序性记忆 :存"如何完成某任务"的可复用步骤。
  7. 基础设施抽象 :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 强在哪(窗口内/会话内的工程精度) :

  1. 删除 = replace 而非物理删,日志可回放可审计(事件溯源 + surface + sourceEventSeqs)。
  2. 增量 + provider 校准的 token 计量(replay-aware 折叠、复用真实 usage 分桶、projectedTokens)。
  3. KV 缓存是一等约束(前缀逐字节稳定、压缩前缀重放、记录失效点)。
  4. 压缩可收敛、可合并、事务化(结构化模板、必须短于原文、工具配对安全、start/summary/end + 孤儿锁、崩溃合成恢复)。
  5. 溢出自动恢复 + provider 级重试。

DSH 弱在哪(面试时主动说) :没有长期语义记忆、没有多用户作用域隔离、没有向量/图检索——这三块是 LangGraph Store + StoreSemanticSearch 和 mem0 的地盘。


5. 分层架构与正确组合

flowchart TB
    subgraph WIN["短期/工作记忆(窗口内,DSH 强项)"]
        W1["token 计量"] --> W2["压缩/裁剪/滑窗"]
        W2 --> W3["溢出恢复"]
        W1 --> W4["KV 缓存感知组装"]
    end
    subgraph EXT["长期记忆(窗口外,mem0/LangGraph Store)"]
        E1["提取 + 更新"] --> E2["向量/图检索"]
    end
    E2 -- "按需注入 top-k" --> WIN

核心结论 :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 生产最常用
flowchart LR
    IN["用户输入"] --> R["规则/关键词 快路径"]
    R -->|命中| AG["目标 agent/skill"]
    R -->|未命中| E["嵌入相似度 top-k"]
    E -->|置信 ≥ 阈值| AG
    E -->|低于阈值| L["LLM 兜底分类"]
    L -->|分类成功| AG
    L -->|仍低置信| Q["反问澄清 / 回退默认 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 为单位分别维护。

English / 原文

image.png

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;触发压缩的阈值(thresholdRatio 0.8)和压缩后保留的尾巴(retainRatio 0.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)。

    flowchart LR EV["会话事件日志"] --> FOLD["surface-fold 增量折叠O(surface)"] FOLD --> H["启发式 4字符/token + 结构开销"] USAGE["provider 报的 usageinput/cache-read/cache-write/output"] --> REUSE{"最新信封与当前一致?"} REUSE -- "是" --> H2["复用 provider 数值为锚点"] REUSE -- "否" --> H H2 --> PROJ["projectedTokens = 锚点 + surface 增减"] PROJ --> OUT["contextBreakdownsystem/tools/message 拆解"]

1.3 消息历史存储与派生("删除"语义)+ 投影

  • 问题 :对话历史怎么存、怎么变成每次发给模型的 messages、以及"删掉某些历史"在数据层怎么表达。

  • 传统 :

    • LangChain:内存 messages 列表;ConversationBufferWindowMemory / TokenBufferMemory 直接从数组 drop 旧消息,真删,删了无法回放。
    • LangGraph:state.messages + checkpointer(MemorySaver/SqliteSaver/PostgresSaver)按 thread 持久化快照;RemoveMessage(id) 从 state 移除。
  • DSH (dsh-session):

    • 事件溯源 :Session 是 append-only 事件日志(唯一事实源),模型消息从日志派生(deriveMessages() 投影成 surface)。
    • 删除 = replace :追加一条带 surfaceOp: {op:'replace', start, end} 的新事件,旧事件仍在日志,只是不再投影。带 sourceEventSeqs 溯源"这条替换覆盖了哪些原始 seq"。
    • 换来:可审计、可确定性回放、可 fork、崩溃可修复。

    flowchart TB subgraph LC["LangChain(旧)"] A1["内存 messages 数组"] --> A2["直接增删,删了无法回放"] end subgraph LG["LangGraph"] B1["state.messages"] --> B2["checkpointer 按 thread 持久化快照"] end subgraph DSH["DSH"] C1["Session:append-only 事件日志(唯一事实源)"] --> C2["deriveMessages() 派生"] C2 --> C3["surface:模型可见消息"] C3 --> C4["replace 覆盖旧 span,日志永不删"] end

投影(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,或 surfaceOp span 覆盖它)反查。

    flowchart TB subgraph LOG["日志(原值)"] E1["seq=1 user/message"] E2["seq=2 assistant/message"] E3["seq=3 tool/result"] E4["seq=4 user/messagesurfaceOp: replace 1-3sourceEventSeqs: [1,2,3]"] end subgraph SURF["surface(投影)"] S1["summary 消息(由 E4 投影)"] end E4 -->|"正向:sourceEventSeqs=[1,2,3]"| S1 E1 -.->|"反向:被 E4 的 span 覆盖"| E4

1.4 压缩总结(compaction)

  • 问题 :把一大段历史换成一小段摘要,尽量不丢关键信息。

  • 传统 :

    • LangChain:ConversationSummaryMemory 每轮把"旧摘要 + 新消息"再浓缩;SummaryBufferMemory 近 N 条保留原文 + 更旧进摘要。自由文本,不保证变短,不处理工具调用。
    • LangGraph:summarize_conversation 预模型钩子,自由文本摘要。
  • 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,中途崩溃留下可检测的孤儿锁。

    sequenceDiagram participant Agent as agent-loop participant C as compaction-basic participant LLM as llm.stream participant S as Session 日志

    Agent->>C: pre-step 检查压力
    C->>S: append compaction/start(加锁)
    C->>LLM: 逐字节重放系统提示+工具+被压缩区间作前缀
    LLM-->>C: 结构化 8 章节摘要
    C->>C: 校验:必须短于原文,否则拒绝+重试
    C->>S: append compaction/summary
    C->>S: append user/message(replace)
    C->>S: append compaction/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 判定。

    flowchart LR T["超预算 tool/result"] --> P["dsh-compaction-tool-result-pruner"] P --> R["保留 head 4096 + tail 1024 字符中间 [... tool result middle pruned ...]"] R --> W["写入 replace 事件,原始结果保留在日志"] T2["超预算文本/条目"] --> O["dsh-output-retention"] O --> O2["ItemRetainer 保前 N 条目TextRetainer head/tail/headTail"] O2 --> O3["返回精确省略计数告诉模型"]

1.6 滑窗 / 最近尾巴(保留最新 N 条)

  • 问题 :只保留最近的部分历史,丢弃更旧的。

  • 传统 :ConversationBufferWindowMemory(k=N) 只留最近 N 次交互,直接丢更旧的 。

  • DSH :retainRatio/retainTokens 保留最近尾巴,但更旧的不是丢,而是压缩成摘要 (组合式)。

  • 最近尾巴 = 压缩时保持原文不动的那段最近消息。必须保留原文,因为最近交换/进行中的工作/未闭合工具调用需要精确状态。

  • 注意 :纯"保留最新 N"按顺序取舍、不按相关性,早期关键约束会被丢掉——这正是需要长期记忆补位的原因。

    flowchart LR OLD["最老 span → 总结成摘要"] -->|replace| SUM["compacted-summary 摘要"] TAIL["最近尾巴 → 保持原文"] --> TAIL2["最近的若干条消息(原文)"]

1.7 溢出恢复

  • 问题 :provider 已报"超窗口",不能直接失败,要走恢复路径。

  • 传统 :手写 try/except + trim 重试。

  • DSH :adapter 归一化 CONTEXT_WINDOW_EXCEEDED → agent/request-error → 绕过阈值强制缩减(prune + 最大平衡头部缩减)→ dsh-llm-retry 退避重试。

    flowchart LR P["provider 报 context_length_exceeded"] --> N["adapter 归一化 CONTEXT_WINDOW_EXCEEDED"] N --> R["agent/request-error 瀑布"] R --> S["裁剪 → 最大平衡头部缩减"] S --> RT["llm-retry 授权重试,指数退避+jitter"] RT --> P

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(技能指令按需加载,非常驻)。

    flowchart TB subgraph ADD["注入(加法)"] A1["dsh-system-prompt:section 固定排序 + 变量插值 + toolOrder"] A2["dsh-time-context / dsh-tmux-context:变化才注入"] A3["dsh-session-reference:跨会话有预算上限的只读快照"] end subgraph ISO["隔离(分流)"] B1["dsh-subagent / fork / workflow:子 agent 独立窗口"] B2["dsh-skill:技能指令按需加载"] end

KV 缓存(跨请求 prompt 前缀缓存) :

  • 推理层 KV cache:生成时缓存已算 token 的 K/V 向量,避免每步重算注意力。

  • 跨请求 prompt cache(上下文管理谈的是这个):provider 缓存 prompt 前缀的 K/V;新请求前缀逐字节一致就复用、只算新后缀,命中部分按 cache_read 计费(更便宜)。

  • 存/查:服务端按前缀哈希存、带 TTL;客户端不能直接查缓存,只能通过 usage 字段(cache_read_tokens/cache_write_tokens)观察命中。

  • 客户端如何配合命中:静态内容放最前且排序固定;历史只 append 不中间插入;压缩时逐字节重放前缀。

    flowchart LR A["静态内容放最前 system prompt + 工具 schema"] --> B["排序固定、逐字节稳定"] B --> C["历史只 append,不在中间插入/修改"] C --> D["压缩时逐字节重放前缀"] D --> E["新内容只出现在末尾"]


2. 决策依据:什么时候做什么

唯一度量源 :ctx.tokenMeter.measure() 的压力值 vs 路由模型 resolveModelInfo().context 容量。

三个触发点 × 动作阶梯 :

flowchart TB
    P["pre-step 发请求前"] --> M["measure() 测压力"]
    M --> T{"压力 ≥ window × thresholdRatio?"}
    T -- "否" --> GO["正常发请求"]
    T -- "是" --> PR["① pruner 裁剪超预算 tool result"]
    PR --> M2["② 重测"]
    M2 --> T2{"仍超?"}
    T2 -- "否" --> GO
    T2 -- "是" --> SUM["③ LLM 压缩,压最老 span 留尾巴"]
    SUM --> M3["④ 重测,超则 compactionRetries 重试"]
    M3 --> GO
    GO --> ERR{"provider 报溢出?"}
    ERR -- "是" --> REC["绕过阈值:prune + 最大平衡头部缩减 + 重试"]
    REC --> GO

决策表 :

触发点 条件 动作 参数
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 缓存(配合) 持续约束(非触发) 组装/压缩时保持前缀稳定 压缩时重放前缀复用

时序关键点(为什么是这个顺序) :

  1. 缩减动作按成本从低到高:先裁剪(无 LLM 调用)→ 重测 → 不够才压缩(调 LLM) 。
  2. 正常路径只在 pre-step 做一次压力检查;溢出路径在 request-error 强制缩减,两者不重叠。
  3. 计量是"持续维护 + 按需读取":事件驱动更新,决策点才 measure()。

2.3 一次请求的生命周期(各策略挂在哪个钩子)

sequenceDiagram
    participant User as 用户输入
    participant Pre as agent/pre-step
    participant Derive as 请求派生
    participant LLM as llm.stream
    participant Err as agent/request-error
    participant Log as Session 日志

    User->>Pre: 进入 inbox
    Pre->>Pre: 组装 system-prompt(每步)
    Pre->>Pre: time/tmux 注入(变化才注入)
    Pre->>Pre: measure() 压力检查
    Pre->>Pre: 超阈值则 pruner 裁剪 → 重测 → 压缩
    Pre->>Derive: 请求派生(deriveMessages + header)
    Derive->>LLM: 发送请求
    LLM-->>Log: 成功:记录 assistant + usage(token-meter 增量)
    LLM-->>Err: 失败
    Err->>Err: 溢出则溢出恢复;其他则 llm-retry 退避重试
    Err->>Derive: 重试(同一历史)

3. mem0

3.1 定义

mem0 是一个开源的记忆层(memory layer) ,给 LLM 应用/agent 提供独立于上下文窗口的长期记忆。论文:Mem0 (arXiv 2504.19413)。

两阶段流水线 :

  1. 提取 :一个 LLM 从对话消息里抽取"值得记的事实/偏好/决策",输出结构化 memory 条目(JSON)。
  2. 更新 + 存储 :对每条候选记忆,LLM 拿它和已有相关记忆比较,判定 ADD / UPDATE / DELETE / NOOP ,写入向量库。
  3. 检索 :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(向量化)。

flowchart LR
    M["对话消息"] --> E["LLM 提取事实/偏好/决策"]
    E --> C["候选记忆条目 JSON"]
    C --> U["LLM 判定 ADD/UPDATE/DELETE/NOOP"]
    U --> V[("向量库 Qdrant/Chroma/pgvector")]
    U --> H[("历史库 SQLite/Postgres")]
    U --> G[("图库 Neo4j / Graph Memory")]
    Q["查询 query + user_id/agent_id"] --> EM["embedder 向量化"]
    EM --> SR["相似度检索 + scope 过滤"]
    V --> SR
    SR --> K["top-k 相关记忆"]
    K --> I["作为文本注入上下文"]

作用域与类型 (官方 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 新增能力(逐条)

  1. 外部持久化存储 :记忆大小受向量库容量约束,不受窗口约束。
  2. 自我更新 :add/update/delete/noop 判定,会去重、改写错误、合并冲突,长期收敛。
  3. 按相关性检索而非按顺序保留 :解决"早期关键约束被滑窗丢掉"的问题。
  4. 作用域隔离 :user_id/agent_id/run_id 过滤,多用户多 agent 互不污染。
  5. 图记忆 :实体-关系存 Neo4j,支持关系查询和多跳推理;向量找"相似",图找"关系"。
  6. 程序性记忆 :存"如何完成某任务"的可复用步骤。
  7. 基础设施抽象 :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 强在哪(窗口内/会话内的工程精度) :

  1. 删除 = replace 而非物理删,日志可回放可审计(事件溯源 + surface + sourceEventSeqs)。
  2. 增量 + provider 校准的 token 计量(replay-aware 折叠、复用真实 usage 分桶、projectedTokens)。
  3. KV 缓存是一等约束(前缀逐字节稳定、压缩前缀重放、记录失效点)。
  4. 压缩可收敛、可合并、事务化(结构化模板、必须短于原文、工具配对安全、start/summary/end + 孤儿锁、崩溃合成恢复)。
  5. 溢出自动恢复 + provider 级重试。

DSH 弱在哪(面试时主动说) :没有长期语义记忆、没有多用户作用域隔离、没有向量/图检索——这三块是 LangGraph Store + StoreSemanticSearch 和 mem0 的地盘。


5. 分层架构与正确组合

flowchart TB
    subgraph WIN["短期/工作记忆(窗口内,DSH 强项)"]
        W1["token 计量"] --> W2["压缩/裁剪/滑窗"]
        W2 --> W3["溢出恢复"]
        W1 --> W4["KV 缓存感知组装"]
    end
    subgraph EXT["长期记忆(窗口外,mem0/LangGraph Store)"]
        E1["提取 + 更新"] --> E2["向量/图检索"]
    end
    E2 -- "按需注入 top-k" --> WIN

核心结论 :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 生产最常用
flowchart LR
    IN["用户输入"] --> R["规则/关键词 快路径"]
    R -->|命中| AG["目标 agent/skill"]
    R -->|未命中| E["嵌入相似度 top-k"]
    E -->|置信 ≥ 阈值| AG
    E -->|低于阈值| L["LLM 兜底分类"]
    L -->|分类成功| AG
    L -->|仍低置信| Q["反问澄清 / 回退默认 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 为单位分别维护。