feat(cloud-agent): 单步输出上限与秒级墙钟,超时/空输出可自恢复

画布 Agent 一轮要跑很多步模型调用,此前只有"按任务类型的分钟级超时":某一步卡住只能等
整轮判死;更根本的是每一步请求根本不带输出上限,不写 max_tokens 时上游按剩余上下文放行,
思考模型可以把单步拖到几分钟,且偶发把整个输出预算花在推理上、正文与工具调用皆空。

- 新增运行时策略 task.agentStepTimeoutSeconds 与 task.agentStepMaxOutputTokens:秒级墙钟
  与单步输出上限,都支持环境变量给部署级默认值、管理端热更新;旧配置 JSON 缺字段按默认值
  回填,无需数据迁移。
- 单步墙钟到点中止这一步时不再把整轮判死:以可识别标记记为该步失败,运行期关掉上游思考
  重试同一步一次(model_failure_recovered / step_timeout_retried),重试仍超时才以可读原因
  (model_step_timeout)结束本轮。
- 空输出催办用尽后新增一级升级:关思考 + 放大输出预算重试同一步
  (model_failure_recovered / empty_output_escalated),而不是直接判死。放大档在原值上翻倍
  并以 131072 封顶,策略值为 0 时用 32768 兜底,让重试仍然有界。
- 输出上限真正下发到上游:textOptions.maxOutputTokens 成为任务信封字段,Agent 的两条请求
  路径(内置协议与声明式协议)组装请求体时写入 max_tokens / max_output_tokens,与既有的
  input.MaxOutputTokens 取较小值,避免策略上限超过模型声明的物理上限。
- task_worker:执行时限改用带画布 Agent 分支的 taskExecutionTimeout;执行时限到点本身不再
  被当成"租约丢失",否则任务会停在 running 等租约过期后被反复重跑(实测一次调用被重跑成
  三次上游请求)。续租仍用上游已合入的"不继承父 context 取消"的独立 context。
This commit is contained in:
AetherNo2332 committed 2026-09-22 01:20:36 +08:00
1 parent 0b285f66e8
commit 9f37065e47
11 files changed
+609 -11

No files matched your search

@@ -0,0 +1,55 @@
package app
import (
"time"
"infinite-canvas/backend/internal/platform"
)
// 画布 Agent 单步的执行边界:一次模型调用最多输出多少 token、最多等多久。
// 两个值都来自运行时策略(管理端可改、环境变量可给部署级默认值),"0"是各自的关闭档位。
// cloudAgentStepOperation 是一次"模型调用"任务的操作名。它同时是任务中心里区分
// "这一条任务是 Agent 的某一步"的依据。
const cloudAgentStepOperation = "cloud_agent_step"
// cloudAgentStepBoostFallbackTokens 是"不限制输出"(策略值为 0)时放大重试用的预算:
// 不限制的本意是"让模型写完",重试却必须有个上界,否则一次卡住的调用会一直占着单步墙钟。
const cloudAgentStepBoostFallbackTokens = 32_768
// cloudAgentStepLimits 是一次模型调用实际生效的执行边界。
type cloudAgentStepLimits struct {
// OutputTokens 是本次调用的输出上限;0 表示不限制(只有 Timeout 兜底)。
OutputTokens int
// Timeout 是单步墙钟:策略给了秒级值时用它,否则沿用文本任务超时。
Timeout time.Duration
}
// cloudAgentStepLimits 解析当前生效的单步边界。策略读取失败时退回出厂默认值:
// 拿到一个确定的上界,好过让一次调用无限跑下去。
func (s *Service) cloudAgentStepLimits() cloudAgentStepLimits {
policy, err := s.runtimeConcurrencySetting()
if err != nil {
policy = defaultRuntimePolicy().Task
}
limits := cloudAgentStepLimits{OutputTokens: policy.AgentStepMaxOutputTokens}
if policy.AgentStepTimeoutSeconds > 0 {
limits.Timeout = time.Duration(policy.AgentStepTimeoutSeconds) * time.Second
} else {
limits.Timeout = time.Duration(policy.TextTimeoutMinutes) * time.Minute
}
return limits
}
// cloudAgentStepOutputBudget 把生效上限折算成本步请求要带的 maxOutputTokens:
// 放大档(空输出升级重试)在原值上翻倍并以硬上限封顶;原值为 0(不限制)时用兜底值,
// 让重试仍然有界。
func cloudAgentStepOutputBudget(limits cloudAgentStepLimits, boosted bool) int {
if !boosted {
return limits.OutputTokens
}
if limits.OutputTokens <= 0 {
return cloudAgentStepBoostFallbackTokens
}
return min(limits.OutputTokens*2, platform.MaxRuntimeAgentStepOutputTokens)
}