Skip to content

[v2] 模型请求被限值切断后,Agent 不再反复重发,这一轮显示切断原因 - #369

Merged
EricWvi merged 5 commits into
ora-space:mainfrom
zhangxinhao:fix/1134-agent-retry-guard
Nov 13, 2026
Merged

EricWvi merged 5 commits into
ora-space:mainfrom
zhangxinhao:fix/1134-agent-retry-guard

Conversation

@zhangxinhao

@zhangxinhao zhangxinhao commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

做了什么

模型代理切断一个请求后,Agent 把它当成网络失败原样重发:OpenCode 0.6.11 共发 6 次(首次 + 5 次重发),Claude Code 0.4.5 发 11 次以上;每次把半截回答再流一遍,用户看到同一段内容重复多遍,最后这一轮只显示「已停止」,看不出原因。按 circus #1132 的目标处理(总上限切断后不再重发、空闲超时切断后最多重发 1 次、显示可读的失败原因):

  • 记下请求怎么结束,能在响应头前拒绝(nodeservices/provider.go)。路由的响应记下答复的状态和停止它的模型限值;空闲超时或总上限在响应头之前到时,照旧回固定的 502 provider_unavailable、不中止连接,但同样记下限值。包装 provider 的调用方用 Outcome(w) 读取:completed 只在上游 2xx 且没被切断时为真,limit 是 model_idle_timeout / model_max_duration 或空。RefuseCall 在响应头前按协议回 422 错误(openai 的 {"error":{…}}、anthropic 的 {"type":"error",…}),文字不含 Agent 据以重发的字样:OpenCode 对 408/409/429/5xx,以及报错文字或响应体像网络失败、超时的都会重发(session/retry.ts 的那组正则),所以文字里也不放数字。
  • 每一轮的重发守卫(agentruntime/retry_guard.go、project.go 的 providerFor)。当前轮的每个模型请求都套上这一轮的守卫(requestmetrics 的记录包在守卫里面,被拒的请求不记):
    • 总上限切断后,这一轮之后的请求都用 RefuseCall 拒绝;
    • 空闲超时切断后放行 1 次重发,再被空闲超时切断就同样拒绝;响应头之前和之后到时一样计(上游在思考阶段完全不输出时,就是响应头之前到时);
    • 一起被切断的请求(OpenCode 第一轮会并行发一个标题请求)只算一次;切断之后开始、上游 2xx 且没被切断的请求清零,同一轮后面再卡住还能重发一次;其它失败(上游拒绝、连不上)不改变计数,Agent 对它们的重发归它自己的策略。
  • 失败原因(agentruntime/session.go 的 turnResult)。Node 把所有失败的轮次都记成 turnEnded: cancelled、不带错误文字,所以这一轮最后的请求是被限值切断的时候,结果改为 failed,FailureCode 就是切断原因。
  • 页面显示(frontend-v2/.../history.ts):这两个失败码显示「模型请求超过时长上限被切断,这一轮已停止。」「模型长时间没有输出被切断,这一轮已停止。」,不再落到「回答没能完整读取(…)」。
  • README:根目录环境变量表、nodeservices、agentruntime 同步。

为什么是「照旧断开 + 之后拒绝」,不是「流里发错误事件」

用真实 Node + 插件实测了两种做法(模型绑定换成脚本化的 provider,模拟两种行为;数字是这一轮的正文请求次数):

情况 OpenCode · openai OpenCode · anthropic Claude Code · anthropic
断开(#367 合并后的行为) 6 6 11+
流里发一条协议内错误事件,文字不含重发字样 1 1 2(Claude Code 改发一次非流式请求)
响应头前 422,文字不含重发字样 1 1 6(0.1s 内连发后停),并把错误文字显示成一条回答
同上,文字为 model stream timed out 5(被 Node 的 45s 看门狗打断) 5 6

流内错误挡不住 Claude Code 改发的非流式请求,最后还是要靠拒绝;拒绝对两个 Agent 都有效,而且保留了 #367 的「切断时中止响应」。另外,上游自己返回的 4xx 经 RemoteProvider 会被换成 502 provider_rejected(OpenCode 会重发),所以拒绝必须由 v2-server 自己发。

需要知道的

  • 空闲超时允许的那 1 次重发,仍会把第一次的半截内容和重发的输出拼在一起(最多两段);总上限只剩一段。彻底不拼接要改上游 Agent(重发写进同一条助手消息、不删前一次的片段,ACP 也不标出重发边界),不在本 PR。
  • 默认值下 Node 的无进展看门狗(45s)比空闲超时(2 分钟)短:主会话里模型卡住,通常是 Node 先取消这一轮(页面仍是「已停止」,没有原因);本 PR 的空闲超时那部分主要在 Node 暂停计时(有工具在跑,例如子 Agent)时起作用。
  • 守卫只看本轮:用户发下一条消息是新的一轮,从零开始。

测试

  • nodeservices/outcome_test.go:经真实路由、套一层包装 writer,Outcome 对「响应头之前空闲超时」(502,不中止)、「响应头之后空闲超时」、「总上限」、「上游拒绝」(502,不算正常结束)、「正常结束」分别给出对的结果。

  • agentruntime/retry_guard_test.go:总上限后每个请求都被拒、不到上游,两种协议的拒绝体都对,文字和整个响应体逐条对照 OpenCode v1.19.3 的重发正则;空闲超时放行 1 次、第 2 次起拒绝;正常结束清零,上游失败(502)不清零;切断前已在跑的请求正常结束不清零;一起被切断的请求只算一次;turnResult 对 cancelled + 切断原因给出 failed,没有切断时保持 cancelled,end_turn 不受影响。provider_test.go 断言守卫包在观察者外面、指向当前轮。

  • frontend-v2 history.test.ts:两个失败码显示对应文字。

  • 反向验证:守卫不拒绝时总上限用例失败;去掉「一起被切断只算一次」后对应用例失败;去掉响应头前记下限值后「响应头之前空闲超时」用例失败;任何没被切断的结果都清零时「上游失败不清零」用例失败;去掉前端映射后新用例失败。

  • 真实栈:本 PR 的 v2-server + frontend-v2 + 真实 Node(desktop dcff8afe)+ OpenCode 0.6.11,模型配置(V2_MODEL_*)指向本地假上游,V2_MODEL_IDLE_TIMEOUT=20s、V2_MODEL_MAX_DURATION=60s,在浏览器里发一条消息:

    上游行为 main(含 [v2] 长模型请求不再被令牌到期和固定 5 分钟切断,切断时中止响应 #367) 本 PR
    流出几段后不再输出 5 次正文请求,内容重复 5 段,「已停止」 2 次(首次 + 1 次重发)后拒绝,两段,「模型长时间没有输出被切断,这一轮已停止。」
    一直输出 300 秒内 5 次、仍在重发 1 次后拒绝,「模型请求超过时长上限被切断,这一轮已停止。」
    一直不回响应头(空闲超时临时设为 12s) 3 次以上,直到 Node 的看门狗取消,「已停止」 2 次(首次 + 1 次重发)后拒绝,「模型长时间没有输出被切断,这一轮已停止。」
  • gofmt、go vet ./...、go test -race ./...(v2-server,带 TEST_DATABASE_URL);frontend-v2 npm run check、npm run e2e。

ADR 状态

实现 ora-space/specs 模型代理后续决策(decisions/cloud/model-proxy/20261113-in-flight-model-calls-bounded-by-idle-and-total-limits.md)的 D4;合并后 D1–D4 全部实现。转为 implemented、按最终实现同步 D4 并登记用例证据(按本 PR head 00c2fa52)的 specs PR 是 ora-space/specs#287(落地运行 circus #1142)。合并顺序:先本 PR,再 specs#287。

Refs ora-space/circus#1134

…l before upstream

The route's response now records the cut reason (idle or total limit, cancelled, interrupted)
for CutReason, and RefuseCall answers a call with a protocol-shaped 422 whose text avoids the
words Agents re-send on.
Each active turn guards its model calls: after a total-limit cut every later call of the turn is
refused before upstream; after an idle cut one re-send is allowed, a second stall refuses the
rest. Calls cut together count as one stall. A turn Node records as cancelled whose last call
was cut at a limit fails with that limit as its failure code.

@EricWvi EricWvi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

审的是 head 9d01e0f6,对照 ora-space/specs 已合并的 ADR(246f4498,D4 与不变量 6、7)。422 与错误文字、守卫包在请求记录外面、同时被切断只算一次、这一轮的失败原因和前端文字都符合 D4。有两处要改:

1. 响应头之前的空闲超时没有被守卫计数(必须改)

RemoteProvider.CompleteObserved 里,空闲计时器从发出请求就开始走;上游迟迟不回响应头时,p.client.Do 因 errIdleTimeout 返回错误,代码走 fault(w, 502, "provider_unavailable") 后直接返回,没有 markCut。于是 CutReason(w) 为空,retryGuard.finish 把它当成正常结束,清零 idleCuts 和 last。

我用临时测试在 9d01e0f6 上确认过:上游不发响应头、空闲超时 200ms,结果是 status=502 body={"code":"provider_unavailable"} CutReason=""。

后果:上游在思考阶段完全不输出(ADR D2 的"代价"里提到的情形)时,Agent 对 502 不受限地重发(OpenCode 最多 6 次,每次等满空闲超时),这一轮也不显示原因。D4 和不变量 6 说的"被空闲超时切断"不区分响应头前后。

请让响应头之前的空闲超时(以及总上限,虽然默认下它总晚于空闲超时)同样报出切断原因:守卫照样计数、放行一次、第二次起拒绝,这一轮以 model_idle_timeout 失败。响应头之前的应答仍按根决策 D4 返回固定的错误码,不需要中止连接。请补一条"上游不发响应头"的测试,和现有的响应头后用例并列。

2. 只有正常结束的请求才清零(请一起改)

finish 的 case "": 把所有没有被切断的结果都当成正常结束,包括上游 4xx(换成 502 provider_rejected)、provider_unavailable、provider_response_invalid。ADR 写的是"中间有请求正常结束,之前的空闲超时切断不再计数"。请只在上游返回 2xx、而且没被切断时清零;其余失败不改变计数(Agent 对这些错误的重发由它自己的策略决定,不在 D4 范围)。

提示,不挡本 PR

目前用户停止走的是结束会话(agent_session_ended),不经过 turnResult,所以不会被误标。等 circus #1111 把停止改成"取消一轮"后,Node 对用户停止也报 turnEnded: cancelled:空闲超时切断过一次、用户再停止的一轮,failure() 会返回 model_idle_timeout,被显示成限值失败。ADR D4 要求"这一轮不是用户自己停的"才按切断原因失败。我会把这一条记到 #1111,由它在改停止时区分。

An idle or total limit that expires while waiting for the header still answers the fixed 502
provider_unavailable without aborting, but the route now records the limit. Outcome replaces
CutReason and also tells whether the call completed: a 2xx answer that nothing cut.
…normal completion

The retry guard reads Outcome: a limit reached before the header counts like one after it, and
only a 2xx call that nothing cut clears an earlier idle cut; provider rejections and unreachable
providers leave the count unchanged.
@zhangxinhao

Copy link
Copy Markdown
Contributor Author

两处都改了,新 head 00c2fa52(在原 3 个 commit 之上加 2 个,没有改写已审的 commit):

  1. 响应头之前的限值也计数(f96fe43e):client.Do 因空闲超时或总上限报错时,照旧回固定的 502 provider_unavailable、不中止连接,但路由的响应记下限值。CutReason 换成 Outcome(w),同时给出是否正常结束和限值。守卫照样计数:放行 1 次,第 2 次起拒绝,这一轮以 model_idle_timeout 失败。nodeservices/outcome_test.go 经真实路由、套一层包装 writer,和响应头之后的用例并列覆盖「上游不发响应头」。
  2. 只有正常结束才清零(00c2fa52):只在上游 2xx 且没被切断时清零;provider_rejected、provider_unavailable(非限值)等失败不改变计数。retry_guard_test.go 新增 TestRetryGuardOnlyANormalCompletionClearsAStall(空闲切断 → 重发遇到 502 → 再卡住即拒绝)。

反向验证:去掉响应头前记下限值后「上游不发响应头」用例失败;任何没被切断的结果都清零时新用例失败。真实栈(上游一直不回响应头,空闲超时 12s):9d01e0f6 重发 3 次以上,直到 Node 的看门狗取消,页面只显示「已停止」;00c2fa52 首次 + 1 次重发后拒绝,显示「模型长时间没有输出被切断,这一轮已停止。」。门禁全过(go test -race ./... 带数据库、npm run check、npm run e2e 61 通过 10 跳过)。

关于 #1111 的提示:明白,用户停止改成「取消一轮」时需要区分,按你记的由 #1111 处理。

@EricWvi
EricWvi merged commit 6e26bb7 into ora-space:main Nov 13, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants