Repository navigation
[v2] 模型请求被限值切断后,Agent 不再反复重发,这一轮显示切断原因 - #369
Conversation
…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.
6244bc0 to
9d01e0f
Compare
EricWvi
left a comment
There was a problem hiding this comment.
审的是 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.
|
两处都改了,新 head
反向验证:去掉响应头前记下限值后「上游不发响应头」用例失败;任何没被切断的结果都清零时新用例失败。真实栈(上游一直不回响应头,空闲超时 12s): 关于 #1111 的提示:明白,用户停止改成「取消一轮」时需要区分,按你记的由 #1111 处理。 |
做了什么
模型代理切断一个请求后,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拒绝;agentruntime/session.go的turnResult)。Node 把所有失败的轮次都记成turnEnded: cancelled、不带错误文字,所以这一轮最后的请求是被限值切断的时候,结果改为failed,FailureCode就是切断原因。frontend-v2/.../history.ts):这两个失败码显示「模型请求超过时长上限被切断,这一轮已停止。」「模型长时间没有输出被切断,这一轮已停止。」,不再落到「回答没能完整读取(…)」。nodeservices、agentruntime同步。为什么是「照旧断开 + 之后拒绝」,不是「流里发错误事件」
用真实 Node + 插件实测了两种做法(模型绑定换成脚本化的 provider,模拟两种行为;数字是这一轮的正文请求次数):
model stream timed out流内错误挡不住 Claude Code 改发的非流式请求,最后还是要靠拒绝;拒绝对两个 Agent 都有效,而且保留了 #367 的「切断时中止响应」。另外,上游自己返回的 4xx 经
RemoteProvider会被换成 502provider_rejected(OpenCode 会重发),所以拒绝必须由 v2-server 自己发。需要知道的
测试
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-v2history.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,在浏览器里发一条消息:gofmt、go vet ./...、go test -race ./...(v2-server,带TEST_DATABASE_URL);frontend-v2npm 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 head00c2fa52)的 specs PR 是 ora-space/specs#287(落地运行 circus #1142)。合并顺序:先本 PR,再 specs#287。Refs ora-space/circus#1134