Skip to content

[v2] 长模型请求不再被令牌到期和固定 5 分钟切断,切断时中止响应 - #367

Merged
EricWvi merged 6 commits into
ora-space:mainfrom
zhangxinhao:fix/1122-long-model-request
Nov 13, 2026
Merged

EricWvi merged 6 commits into
ora-space:mainfrom
zhangxinhao:fix/1122-long-model-request

Conversation

@zhangxinhao

@zhangxinhao zhangxinhao commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

做了什么

修 v2-server 的模型代理会切断长的单次模型请求(circus #1075 实测确认),按 owner 在 #1122 的结论:

  • 令牌到期不再切断进行中的请求(nodeservices/grant.go)。model() 去掉 WithDeadline(selected.expires),保留 AfterFunc(grant.ctx):进行中的请求只随吊销(End、删除授权、Close)或 Agent 断开取消;到期的令牌仍不能发起新请求,也不能续期。grant 记录进行中的请求数:已过期但还有请求在跑的 grant 不会被签发时的清理或续期失败取消,仍可被吊销,最后一个请求结束后才删除。原来请求的截止时间固定为它开始时的令牌到期时刻,Node 在剩约 60 秒时续期,最坏只剩约 1 分钟。
  • 5 分钟总时限改为空闲超时 + 总上限(nodeservices/provider.go)。RemoteProvider 去掉 http.Client.Timeout(它包含读完整个流式响应体),并去掉默认 transport 的 ResponseHeaderTimeout,改为空闲超时(从发出请求起计时,每收到一段上游数据重新计时,也覆盖等待响应头,默认 2 分钟)和总上限(默认 30 分钟),SetLimits / Limits 设置和读取。
  • 部署配置(executionruntime/models.go):V2_MODEL_IDLE_TIMEOUT、V2_MODEL_MAX_DURATION(Go duration,两种协议共用);非法值或空闲超时大于总上限时启动失败,不回退默认值。
  • 中途停止时中止响应。原来响应头发出后被切断,流照常结束,OpenCode 把半截内容当成完整回答、带上它继续请求,失败被掩盖成重复内容和成倍调用。现在 provider 在响应头发出后中途停止时(空闲超时、总上限、吊销、上游失败)标记这个响应,由 Agent 模型路由在 Complete 返回后以 http.ErrAbortHandler 中止,Agent 读到的是失败。中止只在拥有这个响应的路由里做:Provider.Complete 也被不是 HTTP handler 的调用方使用(纯模型轮次 modelturn),在里面 panic 会打到它们;标记经 Unwrap 穿过包装 writer(requestmetrics 的 observerWriter 新增 Unwrap)。
  • 请求记录(requestmetrics/recorder.go):中途切断记为 interrupted(model_response_interrupted),吊销或 Agent 断开记为 cancelled。
  • README:v2-server 的环境变量表、nodeservices、executionruntime、requestmetrics 同步。

需要知道的

  • OpenCode 收到中止后仍会重发(OpenCode 0.6.11 实测):原样重发同一个请求(不再带半截内容),指数退避,共 5 次后以 stop_reason: cancelled 结束,不给原因;每次重发期间流出的片段会拼在一起显示。默认值下总上限断开最坏约 2.5 小时、5 倍调用量。owner 定先按本 PR 合入,Agent 一侧由 circus #1132 跟进。
  • 令牌到期不再停止进行中的请求:Node 失联、不再续期时,已开始的请求最长跑到总上限,或直到执行中断、授权被吊销。

测试

  • nodeservices/limits_test.go(缩短的常量):
    • 续期后进行中的请求跑过原到期时刻,完整收到 [DONE];
    • 已过期的 grant:续期被拒、别的执行签发授权触发清理后,进行中的请求仍收到 [DONE],结束后 grant 被移除;之后 End 仍能取消;
    • 等待响应头只受空闲超时约束(默认 client 没有固定的 header 时限);
    • 响应头之后吊销(End)也中止响应;
    • 空闲超时经 Agent 模型路由断开,直接调用和经 Unwrap 包装的 provider 都是;上游请求被取消;
    • 总上限断开一个一直在输出的流;
    • 空闲超时和总上限都断言客户端读到的是错误,不是正常 EOF;SetLimits 的校验与默认值。
  • requestmetrics:中途切断记为 interrupted;observerWriter 实现 Unwrap。nodeservices/metrics_route_test.go 经真实路由、以 ProviderFor 包上 requestmetrics.Wrap,断言客户端读到错误且记录为 interrupted。
  • executionruntime:默认值、两种协议都应用配置值、非法值拒绝启动。
  • 反向验证:恢复 WithDeadline 后续期用例失败;去掉路由的中止后空闲超时、总上限用例失败(客户端读到正常结束的流);清理或续期失败时照旧 cancel,过期用例失败;恢复 ResponseHeaderTimeout 后 header 用例失败;去掉 observerWriter.Unwrap 后 metrics 路由用例失败。
  • 真实 Node + OpenCode(v2-server 固定的 desktop dcff8afe,模型经 NewRemoteProvider 转发到本地慢速上游):令牌剩 70 秒时发起的 3 分钟请求,Node 续期后一次跑完,没有被切断;空闲超时(临时设为 20s)每次在 22.0s 断开,OpenCode 的反应如上。
  • gofmt、go vet ./...、go test -race ./...(v2-server,带 TEST_DATABASE_URL)。未改 frontend-v2。

ADR 状态

v2(phase:1),按 owner 要求仍写明:实现 模型接入根决策(approved)“本决策未解决的问题”第 1 条的结论。结论写成同目录的后续决策 20261113-in-flight-model-calls-bounded-by-idle-and-total-limits.md,在 ora-space/specs#285,状态 proposed(等 owner 评审)。本 PR 合并后它的 D1–D3 已全部实现,评审通过转 approved 后再按实现转 implemented。

Refs ora-space/circus#1122

A model call's deadline was the token expiry at the moment it began, so a
call started shortly before Node's renewal was cut after about a minute even
though the grant was renewed. Expiry now only refuses new calls and renewals;
an in-flight call stops on revocation (End, grant deletion, Close) or client
disconnect, as the model-proxy root decision D3 promises.

Refs ora-space/circus#1122
…cut-off streams

The upstream client had a fixed 5-minute timeout that also covered the
streamed body, so any long reasoning or long output was cut. Replace it with
an idle limit (restarted by every upstream chunk, default 2m) and a total
limit (default 30m).

A call stopped after its header was sent (idle or total limit, revocation,
upstream failure) used to end the stream normally, so the Agent took the
partial text as a complete answer and silently resent the request. The
provider now marks such a response, and the Agent model route aborts it with
http.ErrAbortHandler once Complete returns. Aborting stays with the route
that owns the response, so in-process callers such as pure model turns are
never hit by the panic; request metrics reach the route's response through
Unwrap and record the attempt as interrupted or cancelled.

Refs ora-space/circus#1122
…UT and V2_MODEL_MAX_DURATION

Both protocols' providers take the deployment's idle and total limits;
unset values keep the 2m/30m defaults, invalid values fail startup.

Refs ora-space/circus#1122

@ObsisMc ObsisMc left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

审过 339490b3(基于 b03ac2b4)。三项改动(A1、B1、切断时中止)和 owner 在 circus#1122 的结论一致,没有混入 Agent 一侧的重发或失败原因(circus#1132 的范围)。三个 commit 分得清楚,12 个文件都是代码、测试和 v2-server 的正式 README,没有过程记录。modelturn 的处理是对的:provider 只做标记,由拥有响应的路由来中止;markCut 能经 Unwrap 穿过路由和 provider 之间唯一的包装层(requestmetrics.observerWriter)。计时器和 AfterFunc 都用 defer 停掉,panic(http.ErrAbortHandler) 之前 track 和 model() 的 defer 都会执行,我没有看到 goroutine 泄漏。

本地在 PR head 上跑了 GOMAXPROCS=2 go test -p 1 -count=1 ./internal/nodeservices/... ./internal/executionruntime/... ./internal/requestmetrics/... ./internal/modelturn/...,全部通过。

还有两处和 A1、B1 的承诺不一致,我用临时测试复现了(没有提交),请修改:

  1. 令牌到期后,进行中的请求仍会被另外两条路径取消(grant.go:47、grant.go:92),和 A1、ADR 草稿 D1 不符,也和 grant.go:13 的注释不符。
  2. 默认 transport 的 ResponseHeaderTimeout: 30s 仍然有效,所以等待响应头的时限实际是 30 秒,不是空闲超时的 2 分钟(provider.go:81)。

改完后,PR 描述里「本 PR 合并后它的 D1–D3 已全部实现」才成立。

(grant.go 第 47、92 行不在 diff 范围内,行内意见挂在最近的改动行上,正文里写了实际行号。)

@@ -48,7 +50,7 @@ func (s *Service) issue(w http.ResponseWriter, r *http.Request) {
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

(需要修改)第 47 行:签发新授权时的清理会取消任何执行的已过期授权(!time.Now().Before(g.expires) → g.cancel()),也就会取消这个授权下还在进行的请求。A1 之后,已经开始的请求不再受到期约束,但它会在「同一个项目里任意一个执行下次签发授权」时被切断。这个时刻无法预测,正好落在 PR 描述和 ADR D1 写的代价场景里:Node 失联、不再续期。这也和第 13 行的注释("never by expiry")矛盾。

复现(临时测试,没有提交):execution 的请求开始流式输出(2 秒);300ms 时把它的 grant 设为已过期;再过 200ms 给另一个执行 other 签发授权。结果这个请求在约 0.5 秒时结束,没有收到 [DONE]。

建议:在 grant 上记录进行中的请求数(在 model() 里加减,都在 s.mu 下进行)。清理时,已过期但还有进行中请求的 grant 先留在 map 里,不 cancel;它到期后照样不能发起新请求、不能续期,End、删除授权、Close 仍然能找到并取消它。最后一个请求结束后再删掉它。请补一个测试覆盖这种情况:过期加上别的执行签发授权,进行中的请求仍然收到 [DONE];之后 End 仍能取消它。

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

已改(29050bef)。按建议做:grant 记录进行中的请求数(calls,在 model() 里加减,都在 s.mu 下)。签发时的清理跳过「已过期但还有进行中请求」的 grant,不 cancel;它仍然不能发起新请求、不能续期,End、删除授权、Close 仍能找到并取消它;最后一个请求结束时由 finishCall 删掉它。测试 TestExpiredGrantKeepsRunningCallsUntilRevoked:请求进行中把 grant 设为过期 → 续期被拒(403)→ 另一个执行 other 签发授权(触发清理)→ 用过期令牌发新请求被拒(401)→ 原请求仍收到 [DONE],结束后 grant 已移除;第二段同样过期并清理后 End,请求被取消。去掉修复时该用例失败。

@@ -93,7 +95,7 @@ func (s *Service) changeGrant(w http.ResponseWriter, r *http.Request) {
fault(w, 403, "model_access_revoked")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

(需要修改,或者在 ADR 里写明)第 92–93 行:到期后再续期,会被拒绝并 g.cancel(),同样会取消这个授权下进行中的请求。ADR 草稿 D1 列出的取消原因只有会话结束、执行中断、Node 删除授权、Cloud 关闭和 Agent 断开,「过期后续期被拒绝」不在里面。二选一即可:要么这里只拒绝续期、不取消进行中的请求(处理方式同第 47 行那条);要么把它明确算作吊销,写进 D1,并补一个测试。permits 换了绑定那一支(s.permits[g.execution] != g.binding)算吊销,保持现状没问题。

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

选了第一种(同一个 commit 29050bef):过期后续期只拒绝(403 model_access_revoked),不取消进行中的请求;没有进行中的请求时照旧删掉 grant。permits 换了绑定那一支保持吊销语义不变。单把这一处改回 cancel,上面那个用例也会失败(已试)。这样 ADR 草稿 D1 列出的取消原因不用改。

@@ -53,7 +80,8 @@ func newRemoteProvider(protocol Protocol, baseURL, path, apiKey string, transpor
if transport == nil {
transport = &http.Transport{Proxy: nil, ForceAttemptHTTP2: true, MaxIdleConns: 16, MaxIdleConnsPerHost: 8, IdleConnTimeout: time.Minute, TLSHandshakeTimeout: 10 * time.Second, ResponseHeaderTimeout: 30 * time.Second}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

(需要修改):默认 transport 还带着 ResponseHeaderTimeout: 30 * time.Second。第 136 行的注释、nodeservices/README.md 和 ADR 草稿 D2 都说空闲超时「也覆盖等待响应头」,默认 2 分钟,但实际先触发的是这 30 秒。上游在 30 秒内没有发响应头,就返回 502 provider_unavailable;V2_MODEL_IDLE_TIMEOUT 调大也不影响这一段。典型情况是 stream:false 的长请求,以及首个响应头之前先长时间思考的网关。

复现(临时测试,没有提交):生产默认 transport(只换了信任证书),上游 35 秒后才发响应头,stream:false。结果是 30 秒时返回 502 {"code":"provider_unavailable"},当时 idle=2m0s total=30m0s。

建议:既然空闲计时器已经从派发起覆盖等待响应头,去掉 ResponseHeaderTimeout,让空闲超时成为唯一的时限。请补一个测试:上游晚于旧的 header 时限、但早于空闲超时发出响应头,请求要成功。或者等价地,用短常量断言等待响应头只受空闲超时约束。

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

已改(4f3f9ffb):默认 transport 去掉 ResponseHeaderTimeout,等待响应头只受空闲超时约束(计时器从派发起就在跑)。测试 TestRemoteProviderHeaderWaitBoundedOnlyByIdleLimit:默认 client 没有固定的 header 时限和 client Timeout;短常量下,响应头在空闲超时之前到达时成功(200),晚于空闲超时时 502。恢复 30s 时该用例失败。

if err := p.SetLimits(300*time.Millisecond, time.Minute); err != nil {
t.Fatal(err)
}
for name, provider := range map[string]Provider{"direct": p, "wrapped": unwrapping{p}} {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

(非阻塞):wrapped 用的是测试里自己写的 unwrappingWriter,真实链路上的包装是 requestmetrics.observerWriter,现在只有一个编译期断言。建议加一个路由级用例:Options.ProviderFor 返回 requestmetrics.Wrap(...),断言客户端读到的是错误,记录是 interrupted。另外,吊销(End 或 DELETE 授权)发生在响应头之后时也要中止,这一点现在没有用例覆盖(TestEndCancelsActiveProvider 只验证取消)。可以顺手补一条。

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

两条都补了(38d3117a):metrics_route_test.go(外部测试包,因为 requestmetrics 依赖 nodeservices)用 Options.ProviderFor 返回 requestmetrics.Wrap(...),经真实路由断言客户端读到的是错误、记录为 interrupted;去掉 observerWriter.Unwrap 时该用例失败。TestRevocationAfterHeaderAbortsRemoteResponse:响应头发出、读到数据后 End,客户端读到错误而不是正常结束。

Review of ora-space#367 found two paths where expiry still cancelled an admitted
call: the expired-grant sweep run whenever any execution requests a grant,
and the refused renewal of an expired grant. A grant now counts its
in-flight calls; an expired grant with running calls is kept registered,
without being cancelled, so it still cannot start calls or be renewed while
End, grant deletion and Close can still cancel those calls. Its last call
removes it.

Refs ora-space/circus#1122
…dle limit only

The default transport kept ResponseHeaderTimeout: 30s, so an upstream that
sent its header after 30 seconds failed with 502 although the idle limit,
which already covers the header wait, defaults to 2 minutes.

Refs ora-space/circus#1122

@ObsisMc ObsisMc left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

复审了 38d3117a(在 339490b3 之上追加 29050bef、4f3f9ffb、38d3117a)。上次的意见都已处理:

  • 到期不再取消进行中的请求(29050bef):grant 用 calls 记录进行中的请求,加减都在 s.mu 下。model() 里 calls++ 之后的每个提前返回都经过 defer s.finishCall,panic 中止时也会执行。签发时的清理和到期后的续期,都会跳过还有请求在进行的过期 grant;它仍然不能发新请求、不能续期,End、删除授权和 Close 仍能取消它,最后一个请求结束后再移除。换绑定那一支保留了吊销语义。
  • 等待响应头只受空闲超时约束(4f3f9ffb):默认 transport 去掉了 ResponseHeaderTimeout,空闲计时器从派发起就在计时。TLSHandshakeTimeout 只约束建连,不影响长请求。
  • 测试(38d3117a):经真实路由和 requestmetrics.Wrap 断开,并记为 interrupted;响应头之后被吊销也会中止响应;过期 grant 的清理、续期被拒和 End 都有用例;header 时限也有用例。

本地在 PR head 上跑了 GOMAXPROCS=2 go test -p 1 -count=1 ./internal/nodeservices/... ./internal/executionruntime/... ./internal/requestmetrics/... ./internal/modelturn/...,全部通过。新增和相关的用例另外用 -race -count=3 跑了一遍,也通过。上次用来复现的场景(过期之后,别的执行签发授权)现在由 TestExpiredGrantKeepsRunningCallsUntilRevoked 覆盖。新增的 metrics_route_test.go 是测试,13 个文件都是交付物。

备注,不需要在本 PR 处理:desktop dcff8afe 的 Node 续期失败一次就把访问状态置为 Revoked,并且不重试。之后它关闭授权时会 DELETE grant,这条路径会取消进行中的请求,属于 ADR D1 的「Node 删除授权」,和本 PR 的语义一致。

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.

3 participants