Article
任务明明做完了,Orca 为什么还是判失败?
前言
一个 Orca Goal 跑了快一小时。代码改完了,测试过了,roadmap 更新了,最后 Git commit 都成功了。
但任务还是显示失败。终端里就一句:
DeepSeek provider error: response did not contain content or tool calls
第一反应多半是「偶发空响应,重试就好」。但我停下来的原因不是这个。实际工作已经全部完成,只是因为最后一次汇报没落到协议上,整个任务被判失败。这是个协议正确性的问题——Agent 系统能不能在最后一米接住模型的不稳定,而不是把它压成一个红色错误。
先从 ~/.orca 找到现场
截图只能看到用户视角。排查时我先去 ~/.orca/sessions 找对应的 session JSONL,最后几条记录很清楚:
assistant: Roadmap updated. All verification complete. Committing all changes.
tool: [feat/deepseek-chore d112084] refactor(agent-sdk): migrate ...
usage: 49,909,209 input / 191,567 output / 47,879,040 cache
assistant: content=null, reasoning_content=..., tool_call=update_plan
tool: Plan updated, 3 items completed
session.completed: failed
截图里出现过 .git/index.lock 的沙箱权限错误,但它不是终态故障。后面 commit d112084 成功了,三项 plan 也全部更新为完成。实际失败发生在更后面。
从 TUI 已经显示的 thinking 看,DeepSeek 像是在组织最终汇报,准备告诉用户任务完成。但这个 turn 最后没有产生可见的 content,也没有新的 tool call。
有一个证据边界要说明:历史版本没保存失败请求的原始 SSE frames。所以日志能证明「没有可见 content 或 tool call」,截图能说明用户看到了 thinking,但没法进一步断定服务端最终 payload 究竟是完全空 delta、空白 content,还是 reasoning-only。模型内部像是「想完了」,但从协议角度看,这一轮什么都没交付。
旧重试为什么没有救回来
Orca 当时不是完全没有防御。Provider 遇到空响应会自动重试一次,但旧逻辑把同一个请求原封不动发了一遍。这对网络抖动有效,对确定性的模型行为不一定有效——如果模型第一次在同样的上下文和工具组合下没生成可见正文或工具调用,完全相同的第二次请求大概率还是同一条路。
简单加几次重试也不是好办法。每次重试都增加延迟和成本,而且 TUI 里已经展示过的 thinking 会在重试里再次播放,用户会看到一段自己从没说过的话。
所以这次改成了语义恢复(semantic recovery),原则是:把新 intent 注入请求、保护原始 Conversation、拒绝 reasoning-only 作为成功。具体来说——
第一,恢复请求必须注入新的 intent。不能只是重复上一条 user message,而是要追加一个明确的新指令让模型给出「可见且可验证」的产出(summary、goals_update、exit_plan 等)。这个 intent 不写入原始 Conversation,只在单次 attempt 的请求体里携带,这样即使恢复失败,历史也不会被污染。
第二,保持消息角色合法。如果当前历史以 user message 结束,恢复指令合并进这次请求的用户消息副本;如果前一条是 tool result,则追加一个临时 user message。原始历史不变,tool call 和 tool result 的对应关系不能被打断。
第三,不接受只有 reasoning 没有终态 payload 的响应作为成功。reasoning 对模型很重要,但它不是用户结果。Provider 必须看到非空、可见的 content,或者合法 tool call,才能结束这一轮。
重试成功了,usage 也不能丢
最开始的动机只是修最终汇报。但顺着链路往下看,很快发现另一个问题:如果第一次响应已有 usage,第二次恢复也产生了 usage,只记录成功那次的话,账就少算了。反过来,如果任务恢复、子 Agent 和 cache token 的口径没理清,也可能多算。
这次日志里有个很具体的例子:
input_tokens = 49,909,209
output_tokens = 191,567
cache_tokens = 47,879,040
真正的总 token 应该是 49,909,209 + 191,567 = 50,100,776,但历史 Goal 里保存的是 97,979,816,刚好多加了一次 47,879,040 cache tokens。问题在于 cache tokens 本来就是 input tokens 的子集,不应该再重复算一次。v0.2.21 修正的是此后的增量口径,没有擅自改写历史 goals_1.json;旧记录仍然保留原值。
于是把 usage 链路从头理了一遍:
- 多次 provider attempt 的 usage 要累加;
- 恢复仍然失败时,已经产生的 usage 不能消失;
- 取消发生在第二次请求期间,第一次完成的 usage 仍要保留;
- 子 Agent 的 provider error 和 post-model hook failure 也要入账;
- Goal 的 token 口径统一为 input + output;
- cache 只作为诊断信息,不再重复计费;
- session resume 要有 usage baseline,避免恢复前后的累计值重复合并。
一个空响应问题,最后把 provider、runtime、TUI、GoalStore 和 session history 全拉进了修复范围。Bug 往往出现在一轮请求里,但修它得沿着整个状态链走一遍。
失败不能只剩一个 failed
旧日志最后只有:
{ "type": "session.completed", "status": "failed" }
这对用户界面够用,对事故分析远远不够。没有截图的话,只能知道 session 失败了,却不知道 provider 为什么失败。更麻烦的是,前台 turn、后台 task、controller 和 TUI 过去保存错误的方式还不一致,有些路径只留下一个通用字符串 failed。
所以这次补了失败证据:
session.completed可以保存经过脱敏的 error;- 后台 provider turn 写入 task-correlated 的
background_task.provider_response; - 后台任务不再覆盖全局
session.completed; - task history 和 session JSONL 写入前统一走 secret redactor;
- 状态更新和 usage 持久化尽量放进同一个原子边界。
目的是下次用户只说「失败了」的时候,系统自己能有足够的解释留下来。可靠的 Agent 不一定永远成功,但它失败的时候应该能说清楚为什么。
长任务真正难的是完成语义
这次事故发生在任务最后一米。代码已经提交,plan 已经完成,测试也已经通过,真正丢掉的只是面向用户的最终汇报。
这让我重新想了一下 Goal 的「完成」到底是什么。如果只把模型最后一句话当成完成依据,那最后一句话失败,前面所有已验证的工作就被压成一个红色错误。但如果只看工具和测试,又可能在没有用户结果、没有风险说明、没有交付摘要时提前结束。
至少有三层:工作完成(代码、文档等产物已经生成),验证完成(测试、构建、diff 已经证明结果成立),交付完成(用户收到清晰的结果、证据、边界和下一步)。这次前两层已经完成,第三层失败了。
所以 Orca 不能把它伪装成成功,但也不能让已经产生的工作和证据消失。正确的做法是做一次有语义的恢复;仍然失败时安全暂停,保存原因,让用户可以继续。
为什么修一个空响应,还碰了预算治理
usage 口径修正以后,预算问题自然浮出来了。如果前台 turn 和同步子 Agent 可以同时消耗预算,却各自只看到自己的局部账本,那每个调用出发时都觉得「还有钱」,合起来可能早已超限。
在配置 max_budget_usd 时,新的 runtime 做了几件事:
- provider admission 按同步 session ledger 串行准入;
- 子 Agent 只拿到父任务剩余的 aggregate budget;
- 一个子任务跨过预算后,后续子任务不再继续;
- 预算模式下禁用并行 child batch,并拒绝异步子 Agent;
- 已经执行的 child tool result 先写入历史,再结束任务,保证 session 可恢复;
- 等待预算准入的请求可以响应取消,不会一直卡在 condition variable 上。
这里牺牲了一部分并发。但如果不严格执行,预算就不叫约束了。
把截图变成回归测试
事故修复最怕「这次看起来好了」。最后没有只测一个 empty string,而是把真实故障拆成多层回归:
- streaming empty response 会进入一次语义恢复;
- non-streaming reasoning-only response 会被拒绝;
- 恢复指令不会写入原始 Conversation;
- 前一次已经展示的 reasoning 不会在重试中重复播放;
- 两次 attempt 的 usage 会合并;
- 取消、provider error 和 hook failure 不丢 usage;
- Goal 不再重复计算 cache tokens;
- session resume 不重复累计旧 usage;
- 前后台失败都保存脱敏后的错误;
- detached worker 不覆盖较新的全局完成状态;
- 超预算 child work 的 tool result 仍能进入可恢复历史。
然后再跑完整 workspace tests、Clippy、格式检查、站点构建、SEO、release helper 和真实 DeepSeek API smoke。
正式发布后验证了 GitHub Release、npm registry 和全新 npm exec 安装:
GitHub Release: v0.2.21
npm: @blade-ai/[email protected]
npm exec: orca 0.2.21
修复代码在这里:
434188c09 fix(provider): recover empty DeepSeek turns
正式版本:
最后
以前我会把这种问题归成「模型偶尔抽风」,现在不太这么想了。
模型确实可能在 thinking 之后漏掉终态 content,也可能在相同上下文里重复同一个行为。但 Harness 本来就是用来接住这些不稳定的——什么时候可以重试,重试时该改变什么,哪些临时信息不能污染历史,成本怎么算,失败要留下什么,后台任务能不能覆盖全局状态,长任务在最后一米失败以后前面的证据该怎么保留。
这次任务已经做完,却被系统判成失败。修复之后我反而更清楚了:一个 Agent 可不可靠,不只看它能不能把事情做完,还得看它能不能正确地知道自己已经做完。
Keep Reading