青雲的博客

Article

Orca 又一轮 DeepSeek V4 适配复盘:重新理解 Agent Harness

· 20 分钟阅读

从一串小问题开始

这次 Orca 做了一轮 DeepSeek 适配。

如果只看改动列表,它像一次普通的 provider 兼容工作:update_plan 参数不合 schema,reasoning_content 要不要回放,strict mode 要不要开,globoneOf 要不要改,max_tokens 要不要显式设置,空响应要不要重试。

修到后面,我发现这些问题其实连在一起。接口兼容只是第一层,真正麻烦的是模型行为。

模型会带着自己的先验输出工具参数;API 文档会随着 thinking mode 改变规则;strict mode 有 schema 子集,不能全局开;工具循环里某些字段今天可能被服务端容忍,明天就可能变成 400。

如果 Agent harness 没接住这些细节,最后用户看到的会是任务卡住、面板过期、工具循环断掉,甚至 goal 永远无法结束。

这篇复盘想讲的就是这一层:DeepSeek-native Coding Agent 不能只把 DeepSeek 当成 OpenAI-compatible endpoint。模型有能力是一回事,系统能不能接住模型真实会做的事,是另一回事。

DeepSeek-native Agent harness 让模型、工具、状态和 UI 形成稳定闭环

update_plan 暴露的是模型先验

这次最先暴露出来的问题,是 update_plan

Orca 的 update_plan schema 其实很简单:

{
  "plan": [{ "step": "Inspect code", "status": "in_progress" }]
}

status 只能是 pendingin_progresscompleted

但真实 session 里,DeepSeek 会反复发出另一种形态:

{
  "plan": [{ "completed": true, "status": "completed", "step": "Add tests" }]
}

或者更极端一点:

{
  "plan": [{ "completed": true, "step": "Add tests" }]
}

它大概率读到了 schema,但同时带着从其他 Agent 工具里学来的强先验:任务状态可以用 completed: truein_progress: truepending: true 这种布尔字段表达。Orca 的 schema 不接受这些字段,工具调用就失败了,TUI 里的 plan 面板还停在旧状态。模型继续写代码,继续跑测试,甚至最后总结“已经完成”,但那个 pinned plan 从来没有被真正更新过。

这个问题有点讽刺。update_plan 本来是为了让长任务更可追踪,结果它先因为 schema 太硬,变成了一个会静默 stale 的状态面板。

我先做的是宽容解析,在校验前归一化几种语义明确的形态:

  • completed: true -> status: "completed"
  • in_progress: true -> status: "in_progress"
  • pending: true -> status: "pending"
  • 如果已经有合法 status,布尔字段直接丢掉
  • 其余未知键剥离

这件事听起来不酷,但很关键。Agent harness 不能只站在“模型应该严格听话”的角度设计。真实模型会有先验,会有惯性,会把别的生态里的工具格式带过来。

对这种语义明确、风险可控的偏差,harness 应该接住。很多 Agent 的不稳定,恰恰发生在这种地方:模型输出了一个人看起来“明明懂了”的形态,系统却因为边界太硬,把整条 loop 打断了。

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 的含义也在这里。它要求 agent loop 跟着 DeepSeek 的 thinking mode、tool-call 语义、上下文回放规则一起调整。对普通聊天来说,provider 的这些差异可能只是边角料;对 Coding Agent 来说,它们会横在每一次工具调用之间。

reasoning_content 回放让工具调用后的下一轮请求延续前一段思考状态

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 mode 需要按工具 allowlist 进入 beta endpoint,并为不支持的 schema 或工具保留 fallback

这里最重要的其实是边界感。Agent harness 里的很多能力都不能全局打开。你要知道它适用于哪个工具、哪个 endpoint、哪个模型版本、哪类 schema。

稳定的工程不靠把所有开关都开满。更重要的是知道哪些地方该强,哪些地方要留退路。Agent harness 容易被低估的部分也在这里:它要给每个能力划清生效边界。

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 卡死。

update_plan 和 update_goal 的状态归一化:接住语义明确的错误形态,避免任务卡死

schema 会参与执行

还有一个小问题很能说明 Agent 工程的细节:glob

glob 原来用了 oneOf 来表达两种模式:

  • glob 模式需要 pattern
  • fuzzy 模式需要 query

这在普通 JSON Schema 里很自然。

但 DeepSeek strict mode 支持 anyOf,不支持 oneOf。如果保留 oneOfglob 永远进不了 strict allowlist。

所以这次把它改成了 strict 可接受的结构:可选字段 nullable,分支改成 anyOf,执行器再负责校验具体组合。

这件事看起来像 schema 小修,实际影响的是 strict 能力能不能启用。工具 schema 既是给模型看的说明,也是 provider 约束解码的输入。

以前很多 Agent 习惯把 schema 当“文档”:模型能看懂最好,看不懂就靠错误重试。但在 strict tool calling 里,schema 会参与生成过程本身。它如果用了 provider 不支持的关键字,影响的不只是模型理解,整个 strict 能力都没法启用。

所以 Agent 工具设计不能只看 TypeScript/Rust 里的类型是否优雅,还要看模型端、provider 端、执行器端是不是共享同一套边界。

小问题叠起来,就会变成长任务里的不稳定

除了这些主线问题,这次还补了几个健壮性点。它们单独看都不大,但放在长任务里,会直接影响用户对 Agent 的信任。

先是 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 一个实际调用例子。

最后是 TUI 的诚实性。如果最近一次 update_plan 失败,plan 面板会标记 stale。这不属于核心算法,但很重要。失败后 UI 还像什么都没发生,比明确失败更伤信任。

适配 provider,最后一定要留探针

这次我还专门留了两个实证工具:

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-flashdeepseek-v4-pro。DeepSeek 的 Models & Pricing 页面也已经把 V4 Flash / Pro 作为当前模型入口。既然选择了 DeepSeek-native,就应该按 DeepSeek 当前文档和模型方向来设计,不能靠兼容层侥幸活着。

重新理解 DeepSeek-native

这轮适配做完,我对 DeepSeek-native 这个词的理解更具体了。

以前说 DeepSeek-native,很容易先想到 base URL、模型名、参数映射。做完这一轮以后,我更愿意把它理解成一组运行时约束:V4 默认 thinking,reasoning 会出现在响应里,工具循环还涉及 replay;非 strict 下,模型可能按先验输出不完全合规的 JSON;strict 下,schema 又必须满足 DeepSeek 的子集。这里没有一个万能开关,只能一段一段把边界接起来。

Agent 状态也不能只当成普通聊天历史。plan、goal、workflow、tool result、reasoning replay、TUI pinned state,都会影响任务能不能继续、能不能结束、用户能不能相信。模型发错字段时,错误也不应该只说 schema validation failed。它最好告诉模型合法字段是什么,让模型有机会自我纠正。

Provider 行为还会漂移。今天服务端容忍不回放 reasoning,不代表明天还容忍。今天 /beta strict 稳定,不代表永远没有回退需求。Harness 需要留下探针、fallback 和清晰错误,别把这些变化全都丢给用户感知。

把这些东西放在一起,才像一个 Agent harness:模型负责推理和生成,provider adapter 负责对齐协议,tool registry 负责约束边界,runtime 负责让状态继续往前走,TUI 负责诚实呈现状态,实证探针负责告诉我们线上 API 今天到底是什么样。

所以 DeepSeek-native 更像一种工程承诺。当 DeepSeek 的模型行为、thinking mode、tool calling、strict schema、缓存和长上下文能力都在变化时,harness 要跟着这些变化一起进化。

结尾

这次修复里,最有价值的地方不在某个参数,而在于它把模型和 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

相关文章

评论