Article
接住模型的真实行为:Orca 一轮 DeepSeek 适配实录
前言
这次 Orca 做了一轮 DeepSeek 适配。
表面看,像是在修一堆零碎问题:update_plan 参数不合 schema,reasoning_content 要不要回放,strict mode 要不要开,glob 的 oneOf 要不要改,max_tokens 要不要显式设置,空响应要不要重试。
但我越修越觉得,这不是一组普通 bug。
它更像一次提醒:做 DeepSeek-native Coding Agent,不能只把 DeepSeek 当成 OpenAI-compatible endpoint。
接口兼容只是第一层。
真正麻烦的是模型行为。
模型会带着自己的先验输出工具参数;API 文档会随着 thinking mode 改变规则;strict mode 不是全局开关,而是有 schema 子集;工具循环里某些字段今天可能被服务端容忍,明天就可能变成 400。
如果 Agent harness 没接住这些细节,最后用户看到的就不是“协议不完美”,而是任务卡住、面板过期、工具循环断掉,甚至 goal 永远无法结束。
这才是我更在意的地方。

update_plan 暴露的是模型先验
这次最先暴露出来的问题,是 update_plan。
Orca 的 update_plan schema 其实很简单:
{
"plan": [{ "step": "Inspect code", "status": "in_progress" }]
}
status 只能是 pending、in_progress、completed。
但真实 session 里,DeepSeek 会反复发出另一种形态:
{
"plan": [{ "completed": true, "status": "completed", "step": "Add tests" }]
}
或者更极端一点:
{
"plan": [{ "completed": true, "step": "Add tests" }]
}
这不是模型完全没读 schema。
它更像是模型从其他 Agent 工具里学到了一种强先验:任务状态可以用 completed: true、in_progress: true、pending: true 这种布尔字段表达。
问题是 Orca 的工具 schema 不接受这些字段。
于是工具调用失败,TUI 里的 plan 面板还停留在旧状态。模型继续写代码,继续跑测试,甚至最后总结“已经完成”,但那个 pinned plan 从来没有被真正更新过。
这个问题有点讽刺。
因为 update_plan 这种工具,本来就是为了让长任务更可追踪。结果它自己先因为 schema 太硬,变成了一个会静默变 stale 的状态面板。
所以最先做的不是上来就开 strict。
而是先做宽容解析。
在校验前先归一化:
completed: true->status: "completed"in_progress: true->status: "in_progress"pending: true->status: "pending"- 如果已经有合法
status,布尔字段直接丢掉 - 其余未知键剥离
这件事听起来不酷。
但它很关键。
因为 Agent harness 不能只站在“模型应该严格听话”的角度设计。真实模型会有先验,会有惯性,会把别的生态里的工具格式带过来。
Harness 要做的,不是每次都用错误把模型打回去。
而是识别那些确定可恢复的形态,把它们接住。
reasoning_content 暴露的是旧经验会过期
另一个更有代表性的问题,是 reasoning_content。
早期 DeepSeek R1 的经验是:历史 assistant 消息里的 reasoning 不要回传。回传反而容易出问题。
所以 Orca 里也有一段旧逻辑:assistant replay 时把 reasoning_content 剥掉。
在当时,这是合理的。
但 DeepSeek V4 的 thinking mode 规则已经不一样了。
DeepSeek 官方 Thinking Mode 文档 里现在明确强调:模型执行 tool call 后,后续请求应该把对应的 reasoning_content 传回去,让模型延续前一段 thinking。
这其实是一个很大的转向。
对于普通聊天来说,少带一点 reasoning 可能只是“上下文不完整”。
但对于 Coding Agent 来说,tool call 是常态。读文件、改文件、跑测试、查日志,每一步都要经过工具循环。
如果每个工具边界都把 reasoning 剥掉,就等于每跨一次工具,模型都丢一点连续思考状态。
更麻烦的是,官方文档说错了会 400。虽然我用真实 API 探针测了一下,今天默认端点和 /beta 端点都还容忍“不回放 reasoning_content”,而且 prompt tokens 一样,说明服务端当前没有真正计入这个字段。
但这不能成为继续剥离的理由。
因为从 agent 系统设计看,这已经是一个合规定时炸弹。
Orca 里原本已经有 ProviderReplayState,会把 provider 的 reasoning 发给 TUI 事件流。问题是管道只走了一半:它让人看到了 reasoning,却没有把需要回放的 reasoning 重新注入下一轮 API messages。
这次修复就是把这半条管道补上。
规则也很克制:
- 只有 assistant 消息带 tool_calls 时,才回放
reasoning_content - 空字符串不回放
"(reasoning omitted)"这种占位符不回放- 普通非工具 assistant 回合不强行带 reasoning
这背后的判断是:不是所有历史 reasoning 都应该回放,但工具循环里的 replay state 必须尊重 provider 当前协议。
这也是 DeepSeek-native 的含义。
不是“能请求 DeepSeek API”就够了。
而是你要跟着 DeepSeek 的 thinking mode、tool-call 语义、上下文回放规则一起调整 agent loop。

Strict mode 不能一键全开
解决 update_plan 以后,很自然会想到 strict mode。
DeepSeek 的 Tool Calls 文档 支持 strict: true,可以让工具参数更严格地符合 JSON Schema。
听起来很好。
但它不是一个可以全局打开的开关。
第一,strict mode 是 beta 能力,需要走 /beta endpoint。
第二,它是 function 级别的,不是整个请求级别的。
第三,它支持的是一个 JSON Schema 子集。
比如 anyOf 支持,但 oneOf 不在支持列表里;每个 object 的所有 properties 都要进入 required,可选字段要用 nullable 表达;additionalProperties 要是 false。
这就带来一个很工程化的问题:Orca 有几十个工具,不是每个工具的 schema 都能直接进 strict。
如果为了 update_plan 把所有工具都加上 strict: true,很可能不是修复,而是把整个请求打成 400。
所以这次的做法是 allowlist:
glob
update_goal
update_plan
只有 schema 已经整理到 DeepSeek strict 子集里的工具,才在 /beta 上注入 strict。
同时加 fallback:如果 beta strict schema 被服务端拒绝,就退回非 strict 再试一次。
这里我比较在意的,不是 strict 本身。
而是这个边界感。
Agent harness 里有很多能力都不能“全局拍脑袋打开”。你要知道它适用于哪个工具、哪个 endpoint、哪个模型版本、哪类 schema。
真正稳的工程,不是把所有开关都开满。
而是知道哪些地方该强,哪些地方要留退路。
update_goal 是下一个 update_plan
update_plan 暴露的是短任务面板问题。
但更隐蔽的是 update_goal。
Orca 的 goal mode 里,模型最终只能把目标标成两个终态:
{ "status": "complete" }
或者:
{ "status": "blocked" }
注意这里是 complete,不是 completed。
这就埋了一个和 update_plan 很像,但后果更重的坑。
模型太容易写:
{ "status": "completed" }
或者:
{ "complete": true }
如果只是普通 plan 面板失败,最多是 UI stale。
但 goal 是会影响 stop-hook 和长任务生命周期的。更新失败,就可能导致 goal 永远无法标记完成。
这就是我说的:很多 Agent bug 不是“代码跑不起来”,而是“状态机停不下来”。
所以这次把同样的归一化逻辑也扩展到了 update_goal:
status: "completed"->status: "complete"complete: true->status: "complete"completed: true->status: "complete"blocked: true->status: "blocked"
这不是为了纵容模型乱写。
而是因为这些形态在语义上没有歧义。
既然没有歧义,harness 就应该负责把它们归到同一个内部状态,而不是把整个 goal loop 卡死。

schema 不是文档,它是执行边界
还有一个小问题很能说明 Agent 工程的细节:glob。
glob 原来用了 oneOf 来表达两种模式:
- glob 模式需要
pattern - fuzzy 模式需要
query
这在普通 JSON Schema 里很自然。
但 DeepSeek strict mode 支持 anyOf,不支持 oneOf。如果保留 oneOf,glob 永远进不了 strict allowlist。
所以这次把它改成了 strict 可接受的结构:可选字段 nullable,分支改成 anyOf,执行器再负责校验具体组合。
这件事看起来像 schema 小修。
但它背后其实是一个判断:工具 schema 不只是给模型看的说明书,它也是 provider 约束解码的输入。
以前很多 Agent 习惯把 schema 当“文档”。
模型能看懂最好,看不懂就靠错误重试。
但在 strict tool calling 里,schema 会参与生成过程本身。它如果用了 provider 不支持的关键字,影响的不是“模型理解差一点”,而是整个 strict 能力没法启用。
这就是为什么我越来越觉得,Agent 工具设计不能只看 TypeScript/Rust 里的类型是否优雅。
还要看模型端、provider 端、执行器端是不是同一套边界。

还有几处不显眼,但会影响真实长任务
除了这些主线问题,这次还顺手补了几个健壮性点。
第一,显式设置 max_tokens。
DeepSeek V4 的 模型文档 写了最大输出可以到 384K,但 API 默认值不是一个适合 Agent 长任务依赖的契约。Orca 之前不传 max_tokens,真实日志里已经出现过 finish_reason=length。
所以现在显式设成 128K。
命中 length 时,不再只说“截断了”,而是提示模型分段继续。
第二,空响应重试一次。
之前如果响应没有 content,也没有 tool calls,就直接失败整轮。
但 DeepSeek 偶发空 choice,对 Coding Agent 来说这种失败太浪费。现在会做一次瞬时重试。
第三,工具数量限制。
DeepSeek Chat Completion API 写得很明确,tools 最多 128 个 function。
Orca 内置工具加 MCP 工具以后,很容易超过这个数。
如果不截断也不告警,用户只会看到一次莫名其妙的 400。
现在会在 provider 序列化处 cap 到 128,并打印 warning。
第四,系统提示瘦身。
Orca 原来会把所有工具的完整 JSON schema 也内联进 system prompt。问题是 tools 参数本来已经带 schema 了,重复内联只是在固定前缀里多塞几千 token。
更重要的是,事实证明“说明书”挡不住模型先验。模型明明看过 enum,还是会发 completed: true。
所以提示里保留工具名称和描述,删除完整 schema,同时给 update_plan 一个实际调用例子。
这比堆一大段 schema 更有效。
第五,TUI 要诚实。
如果最近一次 update_plan 失败,plan 面板会标记 stale。
这不是核心算法,但很重要。
Agent 最伤信任的状态,不是失败。
而是失败了,但 UI 还像什么都没发生。
我为什么要留 real API 探针
这次我还专门留了两个实证工具:
cargo run -p orca-provider --example update_plan_strict_realapi
cargo run -p orca-provider --example reasoning_replay_realapi
一个测 DeepSeek strict mode 下 update_plan 到底能不能稳定生成合规参数。
一个测 reasoning_content 回放和不回放,在默认端点和 /beta 端点分别会发生什么。
我越来越觉得,做 provider 适配不能只靠单元测试。
单元测试能证明我们自己的序列化和解析逻辑没错。
但 provider 行为是活的。
文档会改,模型会换,服务端容忍度也可能变化。
所以这类 real API probe 很有价值。它不应该跑在每次 CI 里,也不适合默认消耗用户 token,但它应该留在仓库里,作为以后升级模型、切 endpoint、排查 400 的实证工具。
这次实测结果也很有意思:
- strict
update_plan在/beta端点能跑通 - 带不带
reasoning_content今天都被服务端接受 - 两种 replay 的 prompt tokens 也一样
这说明一个事实:当前线上行为比文档更宽容。
但工程上不能押这个宽容永远存在。
尤其 Orca 是只围绕 DeepSeek 建设的 agent,只支持 deepseek-v4-flash 和 deepseek-v4-pro。DeepSeek 的 Models & Pricing 页面也已经把 V4 Flash / Pro 作为当前模型入口。既然选择了 DeepSeek-native,就应该按 DeepSeek 当前文档和模型方向来设计,而不是靠兼容层侥幸活着。
DeepSeek-native 到底意味着什么
这轮适配做完,我对 DeepSeek-native 这个词的理解更具体了。
它不是把 base URL 换成 https://api.deepseek.com。
也不是把模型名改成 deepseek-v4-flash、deepseek-v4-pro。
真正的 DeepSeek-native,至少包含几件事。
第一,要理解 thinking mode。
V4 默认 thinking,reasoning 会出现在响应里,工具循环还涉及 replay。你不能拿非 thinking 模型的历史经验,直接套到 V4 上。
第二,要理解 tool calling 的真实边界。
非 strict 下,模型可能按先验输出不完全合规的 JSON;strict 下,schema 又必须满足 DeepSeek 的子集。两边都要接住。
第三,要理解 Agent 状态不是普通聊天历史。
plan、goal、workflow、tool result、reasoning replay、TUI pinned state,这些都不是“消息数组”这么简单。它们会影响任务能不能继续、能不能结束、用户能不能相信。
第四,要允许 provider 行为漂移。
今天服务端容忍不回放 reasoning,不代表明天还容忍。今天 /beta strict 稳定,不代表永远没有回退需求。Harness 要为这种漂移留探针、fallback 和清晰错误。
第五,要把错误教学化。
当模型发错字段时,错误不能只说“schema validation failed”。它最好告诉模型合法字段是什么。DeepSeek 非 strict 下总会有漏网,给模型自我纠正的机会,比单纯失败更有用。
这些东西加起来,才像一个 Agent harness。
模型负责推理和生成。
Provider adapter 负责对齐协议。
Tool registry 负责约束边界。
Runtime 负责让状态继续往前走。
TUI 负责诚实呈现状态。
实证探针负责告诉我们:线上 API 今天到底是什么样。
这是一整套系统,不是一个 SDK wrapper。
结尾
这次修复里,我最有感触的不是某个具体参数。
不是 reasoning_content。
不是 strict: true。
也不是 max_tokens: 128000。
真正有价值的是它暴露了一个事实:Agent 的可靠性,经常死在模型和 harness 之间那些看起来很小的缝隙里。
模型发了一个 completed: true。
工具 schema 不认。
Plan 面板就 stale。
模型少回放一段 reasoning。
今天没事,明天可能 400。
oneOf 写得很标准。
Provider strict mode 不支持。
MCP 工具多挂几个。
请求直接超过 128 functions。
这些都不是宏大问题。
但它们会在长任务里叠起来,最后变成用户眼里的“不稳定”。
所以我越来越觉得,做 Coding Agent 不能只问模型多强。
还要问:这个系统有没有认真接住模型的真实行为?
Orca 选择只围绕 DeepSeek 做,而不是做一层泛泛的多模型抽象。这条路的好处是可以更深地吃到 DeepSeek 的特性,比如 thinking、长上下文、缓存、V4 的 agent 能力。
但代价也很明确:
你必须认真适配它。
不是文档读一遍就算适配。
而是要在每一个工具循环、每一个状态更新、每一个错误边界里,把模型和系统真正扣在一起。
这件事不炫。
但它决定了一个 Agent 能不能从“能跑 demo”,走到“能长期交付”。
Keep Reading