📝 原文全文(逐字保留)

最近把一个专为 DeepSeek 优化的 Agent 框架开源了,叫 Reasonix

这篇文章聊聊为什么值得给 DeepSeek 做一个专属框架,以及具体怎么做的。

起因:通用框架接 DeepSeek 是浪费

LangChain、LlamaIndex 这些通用框架有个共同缺陷:它们把 DeepSeek 当成"base URL 不一样的 OpenAI"。能用,但 DeepSeek 独有的几个特性完全没被利用

  1. 自动 prefix 缓存:DeepSeek 的缓存命中 token 只按 10% 计费
  2. R1 的 reasoning_content:暴露了模型的推理链
  3. 便宜:比 Claude Sonnet 便宜 20 倍,某些"研究玩法"变成日常可用

Reasonix 的三个 Pillar 就围绕这三点设计。

Pillar 1 — Cache-First Loop
问题

DeepSeek 缓存的触发条件是 请求的 byte prefix 与上次完全一致。通用框架每轮都在:

  • 重排历史顺序
  • 注入新的 timestamp
  • 重构 system prompt
  • 工具列表每次序列化结果不同

结果:实测命中率常常 <20%。

解决

把每次请求的上下文拆成三区,各自有严格不变性:

┌─────────────────────────────────────┐
│ IMMUTABLE PREFIX                    │ ← 整个会话永不变
│   system + tool_specs + few_shots   │   这是缓存的靶子
├─────────────────────────────────────┤
│ APPEND-ONLY LOG                     │ ← 只能追加
│   [user₁][assistant₁][tool₁]...     │   旧 turn 作为新 turn 的 prefix
├─────────────────────────────────────┤
│ VOLATILE SCRATCH                    │ ← 每轮重置
│   R1 思考、临时 plan state          │   永不上传
└─────────────────────────────────────┘

Prefix 一启动就 hash 冻结。Log 的 append() 方法禁止任何 mutate。Scratch 每轮 reset()。

实测

5 轮中文多轮对话(deepseek-chat):

指标数值
缓存命中率85.2%
总成本$0.000923
如果跑 Claude Sonnet 4.6$0.015174
节省93.9%

同样的对话上 tool-use(计算器 tool),2 轮:命中率 94.9%,省 95.8%。

数字不是预估,是实打实跑 DeepSeek API 拿到的。

Pillar 2 — R1 Thought Harvesting
问题

deepseek-reasoner 会在 reasoning_content 里输出很长的思考链。通用框架:

  • 要么把它直接回传给下一轮(DeepSeek 官方明确不推荐,会降低效果)
  • 要么把它显示给用户看一眼就扔掉

里面的规划信号完全没利用。

解决

R1 思考结束后,再发一次 V3 请求(便宜,~$0.0001)在 JSON 模式下提取:

interface TypedPlanState {
  subgoals: string[];      // R1 识别出的子目标
  hypotheses: string[];    // R1 探讨的假设
  uncertainties: string[]; // R1 标记的不确定点
  rejectedPaths: string[]; // R1 考虑后放弃的路径
}

实际效果(问一道"3 个盒子标签全错,怎么只摸一个水果确定全部内容"逻辑题):

‹ subgoals (3): 列出所有可能的标签与内容组合 · 确定从哪个盒子摸水果 · 验证唯一性
‹ hypotheses (3): 从"苹果"标签盒摸 · 从"橘子"标签盒摸 · 从"混合"标签盒摸
‹ uncertainties (2): 摸到水果是否能唯一确定 · 混合盒摸到的概率
‹ rejected (2): 从"苹果"盒摸(信息量不足) · 从"橘子"盒摸(对称问题)

这不是幻觉——真实对应 R1 思考链里的实际内容。

Opt-in,默认关。开启方式:CLI --harvest 或 TUI 内 /harvest on

Pillar 3 — Tool-Call Repair

DeepSeek 的 function calling 有几个已知 bug,通用框架不处理就直接崩:

  • 深嵌套 schema 丢参数:工具 schema 有 >10 个参数或嵌套 >2 层时,V3/R1 经常漏字段
  • R1 把 tool call JSON 藏在 <think> 里:忘了冒泡到 tool_calls 字段
  • max_tokens 截断 tool arguments JSON:导致下游 JSON.parse 崩
  • Call-storm:同一个工具 + 同样参数连续调用

Reasonix 的 Repair 层四个模块对应修复:

// 1. Auto-flatten 深 schema
ToolRegistry.register({
  name: "updateProfile",
  parameters: {
    type: "object",
    properties: {
      user: { type: "object", properties: {
        profile: { type: "object", properties: {
          name: { type: "string" },
          age: { type: "integer" },
        }},
      }},
    },
  },
  fn: ({ user }) => updateInDB(user),
});
// → 自动转成 {"user.profile.name": "...", "user.profile.age": ...} 发给模型
// → dispatch 时自动嵌套回 {user: {profile: {...}}} 再调用

// 2. Scavenge 从 <think> 捞回工具调用
// 3. Truncation 恢复(闭合括号、补 null、去尾逗号)
// 4. Call-storm 滑动窗口熔断

全部默认开启,用户不用配置。

附加:Self-Consistency Branching

DeepSeek 便宜到什么程度?3 个并行 R1 样本还是比单次 Claude 便宜。于是把研究论文里的"self-consistency sampling"变成了日常可用:

# 一键开
reasonix chat --branch 3
# 或 TUI 内: /preset max

TUI 里 3 路采样并行完成后显示:

🔀 branched 3 samples → picked #1   #0 T=0.0 u=2   #1 T=0.5 u=0   #2 T=1.0 u=3

u= 是 Pillar 2 harvest 出来的 uncertainties.length。默认选择器挑 u 最少的那路。

实测:R1 中等难度题,3 路分支能稳定提升正确率 ~10-15pp,成本约单次 Claude 的 1/5。

对比 LangChain 裸接 DeepSeek
Reasonix 默认通用框架
Prefix 稳定 → 缓存命中✅ 85-95%❌ 每轮 prefix 漂移
深 schema 自动拍平❌ 丢参数
429/503 指数退避重试❌ 自己接 callback
Tool call 从 <think> 捞
Call-storm 熔断
实时成本/命中率面板✅ TUI
零配置开箱✅ npx reasonix chat

即便完全不开 harvest / branching,默认配置相对"LangChain + DeepSeek"已经天然省 40%+(光 Pillar 1 贡献)。

上手
npm install -g reasonix
reasonix chat

第一次跑会弹输入框让你粘 DeepSeek API key(https://platform.deepseek.com/api_keys 免费额度),存到 ~/.reasonix/config.json。之后一条命令即用。

进阶

TUI 里所有功能用 slash 命令切换,不用记 CLI 参数:

/preset fast              默认配置(deepseek-chat,最便宜)
/preset smart             reasoner + harvest(~10x 成本)
/preset max               reasoner + harvest + branch3(~30x 成本,质量最高)
/model deepseek-reasoner  只切模型
/harvest on               只开 harvest
/branch 3                 只开 3 路采样
/sessions                 列出所有会话
/forget                   删除当前会话
/help                     完整列表

会话默认持久化——下次进来自动恢复上次的上下文。用过 Claude Code 的熟悉这个模式。

技术栈
  • TypeScript + Node 20+
  • Ink(React-based TUI,Claude Code 同款)
  • 原生 fetch + SSE 流式
  • Vitest(135 个测试通过
  • Biome lint
  • tsup 打包,ESM 输出

MIT 开源,目前 v0.0.6 pre-alpha。

最后

这个项目是 故意窄 的:

  • ❌ 不做多 provider 兼容(LiteLLM 专门做这个)
  • ❌ 不做多 agent 编排(LangGraph 专门做这个)
  • ❌ 不做 RAG(LlamaIndex 专门做这个)
  • ✅ 只做 DeepSeek,做到深

如果你有在 DeepSeek 上跑 agent 的工作负载,欢迎试试 → https://github.com/esengine/reasonix

issue 和 PR 都欢迎。尤其是 Pillar 2 的 branching 选择策略目前只看 uncertainties.length,想听听大家对改进方向的意见——比如能不能用 tool call 成功率、回答长度比、cross-sample 相似度等信号一起判断。

👤 用户 · 对 Pillar 1 原理追问

Pillar 1 — Cache-First Loop

问题

DeepSeek 缓存的触发条件是 请求的 byte prefix 与上次完全一致。通用框架每轮都在:

重排历史顺序

注入新的 timestamp

重构 system prompt

工具列表每次序列化结果不同

结果:实测命中率常常 <20%。

解决

把每次请求的上下文拆成三区,各自有严格不变性:

┌─────────────────────────────────────┐
│ IMMUTABLE PREFIX                    │ ← 整个会话永不变
│   system + tool_specs + few_shots   │   这是缓存的靶子
├─────────────────────────────────────┤
│ APPEND-ONLY LOG                     │ ← 只能追加
│   [user₁][assistant₁][tool₁]...     │   旧 turn 作为新 turn 的 prefix
├─────────────────────────────────────┤
│ VOLATILE SCRATCH                    │ ← 每轮重置
│   R1 思考、临时 plan state          │   永不上传
└─────────────────────────────────────┘

Prefix 一启动就 hash 冻结。Log 的 append() 方法禁止任何 mutate。Scratch 每轮 reset()。用中文详细理解介绍原理

🤖 DeepSeek 助手 · Pillar 1 深入解析

下面我来详细拆解 Pillar 1 — Cache-First Loop(缓存优先循环) 的工作原理,把上面那段描述翻译成更通俗、更深入的解释。


背景:DeepSeek 的缓存机制是怎么工作的?

DeepSeek API 提供了一种上下文缓存(context caching)能力。它的触发条件非常“死板”:

> 只要两次请求的 字节前缀 完全一致,前面相同的那一段 token 就不会重复计算,只收取极低的缓存读取费。

举个例子:

如果你第一次发送的请求是 "你是一个助手。用户说:你好",模型处理完后,这段内容会被缓存。

第二次请求时,如果你发送 "你是一个助手。用户说:你好。请继续",那么前缀 "你是一个助手。用户说:你好。" 与第一次完全一样,这部分就能命中缓存,不需要重新推理,成本极低

但如果第二次你发送的是 "你是一个助手。用户说:嗨",前缀 "你是一个助手。用户说:嗨" 和之前不同,缓存就失效了,整段都得重新计算。

这个“完全一致”是字节级别的,非常苛刻。


问题:为什么通用编程 Agent 框架缓存命中率极低?

典型的 AI 编程 Agent(比如一些通用实现)在每一轮对话中,发给 API 的请求都会不断变化,这些变化导致前缀不再稳定,例如:

  • 重排历史顺序

为了优化上下文窗口,有些框架会把之前的对话打乱,把系统认为更重要的消息放到前面。哪怕只是调换了 user 和 assistant 的顺序,整个前缀就完全不同了。

  • 注入时间戳

很多框架会在 system prompt 或历史消息中插入当前时间,比如 "当前时间:2026-05-20 14:23:15"。每一轮时间都在变,即使只差一秒,前缀就变了。

  • 动态重构 system prompt

每次调用都可能根据当前工作目录、项目文件列表、可用工具集等信息重新生成 system prompt,而这些信息每一轮都可能不同(比如新增文件、工具返回值变化)。哪怕只是多了一个空格,缓存都会失效。

  • 工具定义序列化结果不一致

Agent 需要向模型描述它能调用哪些工具(tool_specs)。很多框架在序列化工具列表时,顺序不稳定,或者参数描述中包含了动态数据(比如当前环境信息)。这就导致每次发给 API 的“工具列表”部分字节都不完全一样。

后果:实测中,这些框架的上下文缓存命中率常常 低于 20%,几乎完全享受不到 DeepSeek 的缓存红利,成本居高不下。


解决方案:三区模型(Three-Zone Model)

DeepSeek-Reasonix 为了解决上述问题,把每次发给 API 的上下文强制拆分为三个区域,并对每个区域施加严格的不可变性约束。核心思想是:

> 让请求中能够保持稳定的部分绝对不变,必须变化的部分以严格追加的方式增长,易变的部分完全不上传

下面是三个区域的具体设计。


1. IMMUTABLE PREFIX(不可变前缀区)

内容:system 提示词、工具规范(tool_specs)、少样本示例(few_shots)等,这些在整个会话生命周期内不会发生任何改变。

约束

  • 会话一开始,这个区域就被哈希冻结(hash-frozen),之后无论发生什么,一个字节都不会改动。
  • 所有工具定义都会在初始化时进行一次确定性序列化(比如固定排序、固定格式化),生成一个稳定的字节序列。
  • 这个区域充当了缓存的靶子:因为它在每一轮请求的开头都是完全一样的,所以只要 API 曾处理过这段内容,后续所有请求都能命中这个前缀的缓存。

为什么它是“靶子”?

DeepSeek 缓存是在服务端按请求前缀建立的。只要请求的最开头(比如前几千个 token)始终是这同一段内容,服务端就一定能识别出缓存。即便后面的内容在变化,只要前面这个巨大的“锚点”不变,缓存命中率就有了基本保证。


2. APPEND-ONLY LOG(只追加日志区)

内容:对话历史,也就是 [用户消息₁][助手回复₁][工具调用结果₁][用户消息₂]... 这样的序列。

约束

  • 只能追加,严禁修改。任何已经写入的历史记录都不允许被编辑、删除、重新排序或插入新元素到旧位置。
  • 这个区域实现了一个 append() 方法,除此之外没有任何 mutate 接口。旧有的 [turn] 永远以相同的形式保留在原地。
  • 新的对话轮次只是被追加到日志的末尾。

工作原理与缓存的关系

  • 假设第一轮请求的前缀是:

[不可变前缀] + [本轮用户问题]

  • 第二轮请求的前缀是:

[不可变前缀] + [第一轮完整历史] + [第二轮用户问题]

  • 由于第一轮完整历史在第二轮请求中作为前缀出现,并且它在第二轮中没有被改动过,所以第二轮请求中 [不可变前缀] + [第一轮完整历史] 这一整段,和第一轮请求中 [不可变前缀] + [第一轮用户问题...] 有足够长的相同前缀,能够命中缓存。

更重要的是:第三轮、第四轮……每一轮新请求的前缀,都完整包含了前一轮请求的全部内容(不变),因此缓存命中长度会随着对话增长而自然延伸,几乎每一轮都能复用前面所有的上下文,只对新追加的部分进行推理。

对比通用框架:通用框架经常为了节省 token 而重排历史,或者把较早的消息摘要化,这直接破坏了前缀的连续性,导致缓存断裂。


3. VOLATILE SCRATCH(易失暂存区)

内容:Agent 内部推理时用到的临时数据,比如 DeepSeek-R1 的思考链(thinking traces)、当前任务的规划状态、中间假设等。

约束

  • 这一区永不上传到 API。
  • 每轮 Agent 循环开始时,这个区域会被 reset() 清空,重新构建。
  • 它只存在于 Agent 本地的运行环境里,用来辅助生成下一次请求或解析模型输出,但对 API 请求的字节序列完全不可见

为什么要这样做?

R1 的“思考”过程可能包含大量探索性的、不确定的文字,如果把它也放进请求上下文,一方面会浪费 token,另一方面由于思考过程每一轮都完全不同,会严重破坏前缀稳定性。将这些内容放在本地暂存区,就剥离了最大的不稳定因素,彻底保证了上传内容的结构刚性。


整体效果:为什么能实现 85%–95% 的缓存命中率?

通过这种设计,DeepSeek-Reasonix 向 API 发送的每一次请求,其有效负载可以抽象为:

``

[永远不变的 Prefix] + [只追加不修改的历史 Log] + [本轮新输入]

`

而服务端看到的请求,其前缀始终是 [Prefix] + [Log 的已存在部分]`。只要对话在进行,这个前缀就在单调递增地变长,但从不回退、从不修改,因此:

  • 第一轮之后,Prefix 就被缓存;
  • 第二轮,Prefix + turn1 被缓存;
  • 第三轮,Prefix + turn1 + turn2 被缓存;
  • ……

每一轮请求中,绝大多数字节都是“曾经在之前某轮请求中完整出现过”的前缀,所以缓存命中率极高。根据项目声称的测量结果,实际命中率可以达到 85%–95%,这意味着绝大部分输入 token 都只计缓存读取费,成本可以比没有缓存时降低一个数量级。


总结:Pillar 1 的本质

Cache-First Loop 不是一种普通的缓存优化技巧,而是一套完整的上下文架构设计哲学:它通过不可变性、追加性、隔离性这三个原则,将 Agent 的动态性与 API 缓存机制的静态要求解耦,从而将 DeepSeek 的理论成本优势真正转化为工程上可落地的极致性价比。