Article
一次 Orca 卡死反馈背后:真正要修的不是某个超时
前言
这次修复来自一个很直接的用户反馈:
卡死了。
如果你还不了解 Orca,可以先看官网:orcaagent.dev。这篇不是功能介绍,而是一次真实问题从日志、定位、修复到验证的工程复盘。
如果只看这个描述,很容易把它当成一个普通 bug:是不是某个请求超时了?是不是某个线程没退出?是不是 TUI 没刷新?
但我把那份 session 日志拉出来看了一遍以后,越来越觉得事情没有那么简单。
它不是一个“某行代码写错了”的问题。
更像是一个长会话 Agent 系统必然会撞到的问题:上下文越来越重,工具结果越来越长,历史 reasoning 还在被反复带回模型,压缩又来得太晚。最后用户看到的不是一个明确错误,而是系统像是还活着,但就是不往前走。
这种状态最伤信任。
因为它既没有坦白失败,也没有继续交付。
那个 session 到底说明了什么
这次我没有先猜。
我先把用户给的详细 session 日志缩成一个可复现的回归 fixture,保留最能说明问题的几个特征:
{"type":"session.usage","input_tokens":877257,"output_tokens":20902,"cache_tokens":824576}
{"type":"conversation.message","message":{"role":"assistant","reasoning_content":"<synthetic long reasoning omitted>","tool_calls":[{"id":"call_read","function_name":"read_file","arguments":"{\"path\":\"lib/meta.ts\",\"offset\":175,\"limit\":70}"}]}}
{"type":"conversation.message","message":{"role":"tool","tool_call_id":"call_read","content":"<synthetic full-file output omitted>"}}
这几行其实已经很有信息量。
输入 tokens 已经接近 88 万。
缓存命中很多,说明不是“每一轮都完全重算”的问题。
但对一个 Coding Agent 来说,缓存命中并不等于体验稳定。因为用户感受到的是这一轮能不能继续推进、工具结果能不能被消化、模型是不是还在有效上下文里工作。
真正危险的是下面这几个东西叠在一起:
read_file会把大文件完整读回来,缺少offset / limit这种基本的切片能力。grep / glob这类搜索工具没有默认分页,结果一多就会把上下文冲大。- 历史 assistant 的 raw
reasoning_content还可能进入后续 provider replay。 - proactive compaction 绑在 DeepSeek 1M 上下文的硬上限附近,触发得太晚。
- 用户界面没有足够早地告诉你:系统正在做 context compaction。
所以这不是“DeepSeek 不行”。
也不是“上下文 1M 就万事大吉”。
真正的问题是:Agent harness 没有足够早地管理上下文压力。

我先去看了 Codex 和 Claude Code 怎么处理
这一步挺关键。
很多时候我们修自己的系统,很容易陷在已有实现里。你会下意识问:“我现在这套结构里怎么补一个判断?”
但这次问题不该从某个判断开始。
它应该先问:成熟一点的 coding agent,会不会允许工具无限把上下文撑大?
答案基本是否定的。
Claude Code 的文件读取有 offset 和 limit。这不是一个花哨功能,而是长任务里的基础设施。大文件不能每次都完整塞回模型,模型应该学会读范围。
搜索也一样。grep 结果应该有 head_limit 和 offset,默认只给前面一段,后续让模型自己翻页。
Codex 在上下文管理上还有另一个启发:不要只看模型最大窗口,也要看当前工作窗口的压力。真正影响体验的,不是“理论上还能塞多少”,而是“再塞下去这一轮会不会开始变慢、变贵、不可控”。
我最后没有照搬任何一套协议。
但提炼出来的原则很明确:
长会话稳定性不是靠一次大压缩解决的,而是靠每一层都少制造一点上下文债。

第一刀:让工具别再默认吐太多
最直接的问题是工具。
以前 read_file 更像一个简单读文件工具。给一个 path,它返回内容。短文件没问题,长文件就很容易变成上下文炸弹。
这次我给它加了 ranged read:
{
"path": "notes.txt",
"offset": 2,
"limit": 2
}
输出会变成带行号的片段:
2: two
3: three
如果 offset 超出文件长度,也不会假装读到了东西,而是明确返回:
[file has 1 lines; requested offset 5 is past end]
这个细节我挺在意。
因为 Agent 最怕的是模糊反馈。工具没有读到,就应该说没有读到。不要给模型一个含混结果,让它继续在错误上下文里推。
搜索工具也做了类似处理。
grep 默认只返回前 250 条,glob 默认只返回前 500 个路径。超过以后会提示下一次从哪个 offset 继续:
[Showing first 250 results; use offset=250 to continue]
这不是为了省一点 token。
这是为了把“读很多东西”变成一个可控动作。
模型可以继续翻页,但它不能在自己没意识到的情况下,把几百上千条结果直接倒进上下文。
第二刀:旧 reasoning 不应该一直跟着跑
另一个很隐蔽的问题是 reasoning_content。
Orca 会把 assistant 的 reasoning 存在 transcript 里。这件事本身没问题。历史记录应该尽量完整,方便审计、恢复和调试。
但 transcript 里保存,不等于每次 provider replay 都要把旧 reasoning 再塞回模型。
这两个边界必须分清。
这次修复里,我保留了 transcript 里的 reasoning_content,但正常上下文预算和 DeepSeek replay 都不再把旧 raw reasoning 当成模型可见上下文的一部分。
这背后其实是一个很重要的 harness 原则:
可审计状态和模型可见状态不是一回事。
有些东西应该被记录下来。
但不应该每一轮都重新喂给模型。
如果不分这两层,长会话会越来越重,而且你还很难从表面看出来,因为那些 token 不是用户刚刚输入的,也不是工具刚刚返回的,而是历史里悄悄带回来的。
第三刀:压缩不能等到快撞墙
DeepSeek 有 1M 上下文,这当然是优势。
但这次问题也说明了一件事:大上下文不是不做上下文管理的理由。
如果等到 80% 的 1M 窗口才开始压缩,很多用户体感上的“卡住”早就发生了。
所以这次我加了 soft context pressure。
它不再只问:
有没有超过模型硬上限?
而是先问:
有没有超过当前会话应该保持顺滑的软窗口?
超过软窗口,就走现有 summary compaction 路径,提前把历史压掉。
这里我没有另起一套复杂的新压缩算法。
我更想先把触发时机做对。
因为系统里已经有 summary compaction,真正缺的是“别太晚”。如果一上来就发明第二套压缩协议,很容易把问题变成另一个复杂系统。
这也是我现在做 Orca 越来越克制的一点:
能用已有链路解决的问题,不要为了看起来高级再造一条链路。
第四刀:工具结果先做 micro-compaction
summary compaction 是比较重的动作。
但很多长会话里,真正占上下文的是工具结果。
比如连续读文件、grep、glob、bash 输出、web search 结果、MCP 工具返回。它们一开始有用,但过了几轮以后,很多旧结果只需要保留头尾和摘要标记。
所以这次在 summary compaction 前面,又加了一层 tool output micro-compaction。
它不是总结整段对话。
它只是先把老工具结果压成类似这样的结构:
[tool output micro-compact]
original_bytes: ...
head:
...
tail:
...
这个动作很朴素,但很实用。
因为它保留了可追溯性:你知道这里原来有一大段工具输出,也知道它的大概头尾是什么。
同时它不再允许一个很早之前的工具结果,在后面几十轮里一直完整占着上下文。
第五刀:用户要知道系统正在压缩
卡死最糟糕的地方,不只是慢。
而是用户不知道它在干什么。
所以这次 TUI 也补了一个状态:当 soft compaction 开始时,界面会进入 Compacting context...。
这不是一个大功能,但它对信任很重要。
Agent 系统里很多等待都可以被接受,前提是用户知道系统正在做一件合理的事。
如果什么都不显示,用户只会觉得它挂了。
这也是我最近反复写的观点:Harness 不只是权限、工具和沙箱,也包括状态表达。
一个用户看不到内部状态的系统,很难长期被信任。
顺手把 approval 也改顺了一点
修这轮问题时,还有一个很小但真实的交互改动。
approval 弹窗原来主要靠 y / a / A / n 这种快捷键。
老用户习惯了可以继续用,但对新用户不够直观。所以我把它改成了更明确的数字序列:
1 -> allow this once
2 -> always allow this exact call
3 -> always allow 当前工具
4 -> deny
旧快捷键仍然保留:y / A / a / n。
这件事跟卡死不是同一个根因。
但它属于同一种产品判断:Agent 在等待人类决策时,交互要尽量低摩擦。尤其是长任务里,用户已经在等了,不应该再让他猜一个大小写敏感的快捷键。
最后真正让我满意的是测试
这次修复我最看重的不是改了多少代码。
而是它最后被压进了一组回归测试。
我把用户反馈里的关键形态抽成了 session_stuck_2026_07_08.min.jsonl,然后加了 reported_session_triggers_soft_compaction。
这个测试不关心“当时那台机器为什么慢”。
它关心更稳定的事实:
- 一个接近 88 万 input tokens 的长会话,应该触发 soft compaction。
read_file要尊重offset / limit。- stale reasoning 不应该再进入 provider replay。
- 工具输出增长应该被 micro-compaction 控住。
除此之外,我还修了几条原本容易挂住的 server / shell 测试。
其中有几条测试本身有一个很典型的问题:先等 streaming output,再写 stdin 或 terminate。但实际系统里 output delta 可能要到下一次读/写/终止时才 flush。测试就把自己挂住了。
这类测试很容易误导人。
你以为是实现卡了,其实是测试在等待一个不稳定的时序假设。
最后我把它们改成 marker 驱动:先让子进程明确写一个 marker 文件,证明自己已经进入目标状态,再继续后续动作。
这和修 Agent 本身是一回事:
不要靠猜时间,靠证据推进。

验证过程也没有省
最后这次合并前,我跑的是完整验证:
cargo test --workspace -- --test-threads=1
cargo fmt --check
git diff --check
cargo check --workspace
中间完整 workspace test 不是一次就绿。
先是 workflow_types_contract 暴露了 BackgroundTaskSummary 新增字段后,测试 fixture 没同步。
然后是 shell_session_kill_stops_running_task_and_collects_partial_output 暴露了 sleep 150ms 这种时序假设不稳。
再然后是 session_server_contract 里几条 command/exec streaming 测试会卡住,因为测试在等一个不保证立即出现的 streaming delta。
这些都不是主线功能,但它们很有价值。
因为一个 harness 如果连自己的测试都靠运气推进,那它就很难证明自己能托住 Agent。
最后修完以后,完整 workspace test 通过,提交到了 main:
2e7b5c3a fix: stabilize context handling and approval UX
这次我真正学到的
这次修复以后,我对 Orca 的 context engineering 有一个更明确的判断:
上下文稳定性不是一个 compaction 功能,而是一整条链路。
它从工具 schema 开始。
工具能不能分页,能不能范围读取,能不能明确告诉模型结果被截断了。
它经过 provider replay。
哪些历史应该保留给审计,哪些历史应该继续喂给模型。
它经过 runtime pressure。
什么时候该提前压缩,什么时候只是正常增长。
它也经过 TUI。
用户能不能看到系统正在压缩,而不是误以为卡死。
这几层缺一层,长会话都会慢慢积累债务。
模型越强、上下文越大,这个问题反而越容易被掩盖。因为系统会撑得更久,看起来更能扛,直到某个真实用户任务里突然表现成“卡死”。
所以这次我最后修的,其实不是一个超时。
我修的是 Orca 对长任务的自我约束能力。
这也是我越来越在意 harness 的原因。
Agent 真正走向可用,不是因为它永远不卡。
而是因为它知道自己什么时候快撑不住,知道该提前收束,知道该把状态告诉用户,也知道最后要把修复变成测试。
这件事不酷。
但很关键。
Keep Reading