Files
ARTEX/server/task_llm.go
T
Autumn-27 9d8e0ba988 fix(llm): 提交前安全窗口内对同一 provider 重试,减少 model_error
SDK 的 doStream 只重试建连阶段(拿到 200 之前);流一旦开始,中途断流 /
overloaded / 流内 429 等瞬时故障会直接冒泡成 model_error,零重试。表现为
worker 常出现「无总结,终态 model_error」,甚至只跑了一两个工具就中断。

在 streamTaskLLM(所有任务 agent 的统一出口)里补一层同 provider 退避重试:
- 仅在 committed 之前(还没向调用方交付任何输出)触发,重放相同请求不会
  重复模型输出或工具副作用,绝对安全
- 最多 2 次,退避 0.5s→1s(封顶 4s),ctx 取消立即让位
- 只重试瞬时故障(传输 reset/EOF/timeout、408/429/5xx、流内 overloaded);
  额度耗尽交给 profile 故障转移、上下文过长交给 harness reactive 压缩、
  4xx 确定性拒绝一律不重试
- 给不上时原样落到既有透传/故障转移逻辑,行为不变

新增 3 个回归测试(重试到成功 / 重试耗尽如实上报 / 4xx 不重试),退避以
变量暴露便于测试置零。go test -race 全绿。
2026-08-23 13:02:54 -04:00

553 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package server
import (
"context"
"encoding/json"
"errors"
"fmt"
"iter"
"log"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/Autumn-27/artex/agent"
"github.com/Autumn-27/artex/db"
"github.com/Autumn-27/artex/llmrec"
"github.com/Autumn-27/norma/llm"
"github.com/Autumn-27/norma/memory"
"github.com/Autumn-27/norma/transcript"
)
type taskAgentBundle struct {
runtime *taskLLMRuntime // goal decomposition runtime
plannerRuntime *taskLLMRuntime
workerRuntime *taskLLMRuntime
mainRuntime *taskLLMRuntime
pl *agent.Planner
wk *agent.Worker
main *agent.MainAgent
}
type llmAuditProfile struct {
ID int64 `json:"id"`
Name string `json:"name"`
Format string `json:"format"`
Model string `json:"model"`
}
type llmTransitionAudit struct {
Mode string `json:"mode"` // automatic | manual | exhausted
Reason string `json:"reason"`
Previous *llmAuditProfile `json:"previous,omitempty"`
Next *llmAuditProfile `json:"next,omitempty"`
}
type llmActivityMetadata struct {
LLMTransition llmTransitionAudit `json:"llm_transition"`
}
type taskLLMRuntime struct {
s *Server
taskID string
agentKey string
}
type taskLLMError struct {
taskID string
chainExhausted bool
cause error
}
func (e *taskLLMError) Error() string {
if e.chainExhausted {
return fmt.Sprintf("task %s LLM profile chain exhausted: %v", e.taskID, e.cause)
}
return e.cause.Error()
}
func (e *taskLLMError) Unwrap() error { return e.cause }
func isTaskLLMRuntimeError(err error) bool {
var target *taskLLMError
return errors.As(err, &target)
}
func isTaskLLMChainExhausted(err error) bool {
var target *taskLLMError
return errors.As(err, &target) && target.chainExhausted
}
// isQuotaExhaustedError is intentionally strict. A generic 429, auth error,
// network failure, or 5xx does not rotate providers; the response must explicitly
// identify quota, credits, billing balance, or payment exhaustion.
func isQuotaExhaustedError(err error) bool {
return err != nil && agent.IsQuotaExhaustedMessage(err.Error())
}
type taskLLMSelection struct {
task *Task
profileID int64
revision int64
provider llm.Provider
}
type taskLLMStreamHooks struct {
current func() (taskLLMSelection, error)
exhaust func(taskLLMSelection, error) (db.TaskLLMTransition, error)
transition func(taskLLMSelection, db.TaskLLMTransition, error)
}
// current resolves the provider this role runs on, by precedence:
// Agent 绑定 → 任务 LLM 配置链 → 全局/环境配置。
// 绑定优先于任务链:某个角色被显式指定了模型,就一直跑在那个模型上;绑定不存在或
// 构建失败时才降级到任务链,任务链为空时再降级到全局配置。
// 返回的 profile id 只有走任务链时才非零 —— streamTaskLLM 以此判断额度错误是否
// 应该推进任务的故障转移状态(绑定/全局路径不改任务链状态,沿用既有语义)。
func (r *taskLLMRuntime) current() (*Task, int64, int64, llm.Provider, error) {
taskNum, err := parseTaskID(r.taskID)
if err != nil {
return nil, 0, 0, nil, err
}
pt, err := r.s.m.pg.GetTask(taskNum)
if err != nil {
return nil, 0, 0, nil, err
}
if pt == nil {
return nil, 0, 0, nil, fmt.Errorf("task %s not found", r.taskID)
}
r.s.syncTaskLLMState(pt)
t, _ := r.s.m.Task(r.taskID)
if prov, _, ok := r.s.agentBindingProvider(r.agentKey); ok {
return t, 0, pt.LLMChainRevision, prov, nil
}
if len(pt.LLMProfileIDs) > 0 {
if pt.ActiveLLMProfileID == nil {
return t, 0, pt.LLMChainRevision, nil, &taskLLMError{taskID: r.taskID, chainExhausted: true, cause: errors.New("all selected profiles are quota exhausted")}
}
prov, _, ok := r.s.providerForProfile(*pt.ActiveLLMProfileID)
if !ok {
return t, *pt.ActiveLLMProfileID, pt.LLMChainRevision, nil, fmt.Errorf("LLM profile #%d is missing or invalid", *pt.ActiveLLMProfileID)
}
return t, *pt.ActiveLLMProfileID, pt.LLMChainRevision, prov, nil
}
prov, _, ok := r.s.globalProvider()
if !ok {
return t, 0, pt.LLMChainRevision, nil, fmt.Errorf("task %s has no available fallback LLM provider", r.taskID)
}
return t, 0, pt.LLMChainRevision, prov, nil
}
func parseTaskID(id string) (int64, error) {
n, err := strconv.ParseInt(id, 10, 64)
if err != nil || n <= 0 {
return 0, fmt.Errorf("invalid task id %q", id)
}
return n, nil
}
func (r *taskLLMRuntime) Stream(ctx context.Context, req llm.CompletionRequest) iter.Seq2[llm.StreamEvent, error] {
ctx = llmrec.WithTaskID(ctx, r.taskID)
hooks := taskLLMStreamHooks{
current: func() (taskLLMSelection, error) {
task, profileID, revision, provider, err := r.current()
return taskLLMSelection{task: task, profileID: profileID, revision: revision, provider: provider}, err
},
exhaust: func(selection taskLLMSelection, cause error) (db.TaskLLMTransition, error) {
taskNum, _ := parseTaskID(r.taskID)
transition, err := r.s.m.pg.MarkTaskLLMProfileQuotaExhaustedAtRevision(taskNum, selection.profileID, selection.revision, cause.Error())
if err != nil {
return transition, err
}
if pt, getErr := r.s.m.pg.GetTask(taskNum); getErr == nil && pt != nil {
r.s.syncTaskLLMState(pt)
}
return transition, nil
},
transition: func(selection taskLLMSelection, transition db.TaskLLMTransition, cause error) {
r.s.emitTaskLLMTransition(selection.task, transition, cause)
},
}
return streamTaskLLM(ctx, r.taskID, req, hooks)
}
func streamTaskLLM(ctx context.Context, taskID string, req llm.CompletionRequest, hooks taskLLMStreamHooks) iter.Seq2[llm.StreamEvent, error] {
return func(yield func(llm.StreamEvent, error) bool) {
for {
selection, err := hooks.current()
if err != nil {
yield(llm.StreamEvent{}, err)
return
}
committed := false
var pending []llm.StreamEvent
var streamErr error
// 同 provider 安全窗口重试:committed 之前(还没向调用方交付任何输出)
// 的瞬时失败可以原样重放,不会重复模型输出或工具执行。committed 之后、
// ctx 取消、或确定性/额度错误则跳出,交给下方原有的透传/故障转移逻辑。
for attempt := 0; ; attempt++ {
committed = false
pending = nil
streamErr = nil
for event, err := range selection.provider.Stream(ctx, req) {
if err != nil {
streamErr = err
break
}
if !committed && !streamEventCommitsOutput(event) {
pending = append(pending, event)
continue
}
if !committed {
for _, buffered := range pending {
if !yield(buffered, nil) {
return
}
}
pending = nil
committed = true
}
if !yield(event, nil) {
return
}
}
if streamErr != nil && !committed && ctx.Err() == nil &&
attempt < sameProviderStreamRetries && isRetryableStreamError(streamErr) {
backoff := sameProviderRetryBackoff(attempt)
log.Printf("[task-llm] task %s 提交前流失败,%v 后同 provider 重试 (%d/%d): %v",
taskID, backoff, attempt+1, sameProviderStreamRetries, streamErr)
if sleepCtx(ctx, backoff) {
break // 退避期间 ctx 取消 → 停止重试
}
continue
}
break
}
if streamErr == nil {
for _, buffered := range pending {
if !yield(buffered, nil) {
return
}
}
return
}
// profileID=0 means the explicit chain was cleared while this stable task
// bundle was still in use. Agent/global fallback errors follow the legacy
// behavior and never mutate task failover state.
if selection.profileID == 0 || !isQuotaExhaustedError(streamErr) {
for _, buffered := range pending {
if !yield(buffered, nil) {
return
}
}
yield(llm.StreamEvent{}, streamErr)
return
}
transition, markErr := hooks.exhaust(selection, streamErr)
if markErr != nil {
cause := fmt.Errorf("mark profile quota exhausted after %v: %w", streamErr, markErr)
if committed {
// Output may already have driven tool execution. Report the persistence
// failure, but classify it as router-handled so the worker does not
// replay the entire intent and duplicate those side effects.
yield(llm.StreamEvent{}, &taskLLMError{taskID: taskID, cause: cause})
} else {
yield(llm.StreamEvent{}, cause)
}
return
}
if transition.Advanced && !transition.Stale && hooks.transition != nil {
hooks.transition(selection, transition, streamErr)
}
if committed || (!transition.Stale && transition.NextProfileID == nil) {
yield(llm.StreamEvent{}, &taskLLMError{taskID: taskID, chainExhausted: transition.ChainExhausted, cause: streamErr})
return
}
// No event reached the caller, so replaying the same logical request on
// the next profile cannot duplicate model output or tool execution.
}
}
}
func streamEventCommitsOutput(event llm.StreamEvent) bool {
switch event.Type {
case llm.SETextDelta, llm.SEThinkingDelta, llm.SEToolInputJSON:
return event.Text != ""
case llm.SEToolUseStart, llm.SEMessageDelta, llm.SEMessageStop:
return true
default:
return false
}
}
// 提交前安全窗口内、对同一 provider 的重试次数。SDK 的 doStream 只重试建连阶段
// (拿到 200 之前);流一旦开始,中途断流 / overloaded / 流内 429 等瞬时故障会直接
// 冒泡成 model_error,零重试。只要一个 token 都还没交给调用方(!committed),重放
// 完全相同的请求就不会重复模型输出或工具副作用,因此这里补一层同 provider 退避重试,
// 把这类抖动挡在意图整体重跑之前。
const sameProviderStreamRetries = 2
// sameProviderRetryBackoff 是第 attempt 次重试前的退避(0.5s、1s…,上限 4s),
// 与 SDK backoffSleep 同风格但封顶更小,避免拖住 worker 的收尾/取消响应。
// 以变量形式暴露,便于测试将退避置零。
var sameProviderRetryBackoff = func(attempt int) time.Duration {
return min(500*time.Millisecond*(1<<attempt), 4*time.Second)
}
// isRetryableStreamError 判断「提交前的流失败」是否值得在同一 provider 上重放。
// 瞬时的传输中断 / 供应商过载 / 限流会自行恢复,可安全重试;而以下三类不重试:
// - 额度耗尽:交给 profile 故障转移处理,别在这里白烧重试
// - 上下文过长:相同请求重放也没用,交给 harness 的 reactive 压缩兜底
// - 4xx 确定性拒绝(400/401/403/404/422):到哪个 provider 都一样会失败
func isRetryableStreamError(err error) bool {
if err == nil {
return false
}
if isQuotaExhaustedError(err) {
return false
}
s := strings.ToLower(err.Error())
if strings.Contains(s, "too long") || strings.Contains(s, "context length") ||
strings.Contains(s, "context_length") || strings.Contains(s, "maximum context") ||
strings.Contains(s, "status 413") {
return false
}
for _, code := range []string{"status 400", "status 401", "status 403", "status 404", "status 422"} {
if strings.Contains(s, code) {
return false
}
}
// 其余(传输 reset/EOF/timeout、408/429/5xx、流内 error 事件如 anthropic
// overloaded_error 等)一律视为瞬时,允许重试。
return true
}
// CompactionWindow mirrors current()'s precedence so the context window always
// matches the provider the role will actually stream on.
func (r *taskLLMRuntime) CompactionWindow() int {
if _, cfg, ok := r.s.agentBindingProvider(r.agentKey); ok {
return cfg.CompactionWindow()
}
taskNum, err := parseTaskID(r.taskID)
if err != nil {
return (agent.Config{}).CompactionWindow()
}
chain, err := r.s.m.pg.TaskLLMProfiles(taskNum)
if err != nil {
return (agent.Config{}).CompactionWindow()
}
if len(chain) == 0 {
if _, cfg, ok := r.s.globalProvider(); ok {
return cfg.CompactionWindow()
}
return (agent.Config{}).CompactionWindow()
}
minimum := 0
for _, entry := range chain {
if cfg, ok := r.s.loadProfileConfig(entry.ProfileID); ok {
window := cfg.CompactionWindow()
if minimum == 0 || window < minimum {
minimum = window
}
}
}
if minimum == 0 {
return (agent.Config{}).CompactionWindow()
}
return minimum
}
// agentBindingProvider resolves the profile a role is explicitly bound to
// (agents.llm_profile_id) — the highest-precedence level for task agents. ok=false
// when the role has no binding or the bound profile no longer builds, so callers
// fall through to the task chain. A bound profile stays exclusive unless
// llm_pool_bind_fallback is on, which is what poolForBinding encodes.
func (s *Server) agentBindingProvider(agentKey string) (llm.Provider, agent.Config, bool) {
id := s.effectiveProfileForAgent(agentKey, nil)
if id == nil {
return nil, agent.Config{}, false
}
prov, cfg, ok := s.providerForProfile(*id)
if !ok {
return nil, agent.Config{}, false
}
return s.poolForBinding(*id, prov, cfg), cfg, true
}
// globalProvider returns the process-wide provider (persisted active profile or
// environment config) — the last resort once a role has neither a binding nor a
// task chain.
func (s *Server) globalProvider() (llm.Provider, agent.Config, bool) {
s.cfgMu.Lock()
defer s.cfgMu.Unlock()
if !s.llmOn || s.llmProv == nil {
return nil, agent.Config{}, false
}
return s.llmProv, s.llmCfg, true
}
// taskRuntimeAvailable reports whether every listed role can resolve a provider
// under the runtime precedence in current(): the role's own binding first, then
// the task chain, then global. An exhausted chain is a hard stop for unbound
// roles rather than a silent fall through to global — same as current().
func (s *Server) taskRuntimeAvailable(t *Task, agentKeys ...string) bool {
if t == nil || len(agentKeys) == 0 {
return false
}
state := t.llmStateSnapshot()
unboundReady := false
if len(state.ProfileIDs) > 0 {
if state.ActiveID != nil {
_, _, unboundReady = s.providerForProfile(*state.ActiveID)
}
} else {
_, _, unboundReady = s.globalProvider()
}
for _, key := range agentKeys {
if _, _, ok := s.agentBindingProvider(key); ok {
continue
}
if !unboundReady {
return false
}
}
return true
}
func (s *Server) invalidateTaskAgents() {
s.taskAgentMu.Lock()
s.taskAgents = map[string]*taskAgentBundle{}
s.taskAgentMu.Unlock()
}
func (s *Server) agentsForTask(t *Task) *taskAgentBundle {
s.taskAgentMu.Lock()
defer s.taskAgentMu.Unlock()
if bundle := s.taskAgents[t.ID]; bundle != nil {
return bundle
}
goalRuntime := &taskLLMRuntime{s: s, taskID: t.ID, agentKey: "goals"}
plannerRuntime := &taskLLMRuntime{s: s, taskID: t.ID, agentKey: "planner"}
workerRuntime := &taskLLMRuntime{s: s, taskID: t.ID, agentKey: "worker"}
mainRuntime := &taskLLMRuntime{s: s, taskID: t.ID, agentKey: "mainagent"}
tx := transcript.NewStore(filepath.Join(s.m.dir, "transcripts"))
window := workerRuntime.CompactionWindow()
wk := agent.NewWorker(workerRuntime, "task-router", s.m.dir, tx, window, s.agentMaxTurns("worker"))
wk.SetCompactionWindowResolver(workerRuntime.CompactionWindow)
wk.SetRunTimeout(time.Duration(s.agentRunSeconds("worker")) * time.Second)
wk.SetProxy(s.m.ProxyAddr(), s.m.ProxyCACert())
wk.SetMemory(memory.NewStore(filepath.Join(s.m.dir, "memory")))
wk.SetWebSearch(s.webSearchFor("worker"))
pl := agent.NewPlanner(plannerRuntime, "task-router", s.m.dir, tx, plannerRuntime.CompactionWindow(), s.agentMaxTurns("planner"))
pl.SetCompactionWindowResolver(plannerRuntime.CompactionWindow)
pl.SetKillWork(s.engine.KillWork)
pl.SetSteerWork(s.engine.SteerWork)
pl.SetProxy(s.m.ProxyAddr(), s.m.ProxyCACert())
pl.SetWebSearch(s.webSearchFor("planner"))
main := agent.NewMainAgent(mainRuntime, "task-router", s.m.dir, tx, mainRuntime.CompactionWindow(), s.agentMaxTurns("mainagent"))
main.SetCompactionWindowResolver(mainRuntime.CompactionWindow)
main.SetProxy(s.m.ProxyAddr(), s.m.ProxyCACert())
main.SetWebSearch(s.webSearchFor("mainagent"))
main.SetSteerWork(s.engine.SteerWork) // steer_work:人对运行中 work 实时纠偏
bundle := &taskAgentBundle{
runtime: goalRuntime, plannerRuntime: plannerRuntime, workerRuntime: workerRuntime,
mainRuntime: mainRuntime, pl: pl, wk: wk, main: main,
}
s.taskAgents[t.ID] = bundle
return bundle
}
func (s *Server) syncTaskLLMState(pt *db.Task) {
if pt == nil {
return
}
id := fmt.Sprintf("%d", pt.ID)
s.m.mu.Lock()
if task := s.m.tasks[id]; task != nil {
task.setLLMState(pt.LLMProfileID, pt.ActiveLLMProfileID, pt.LLMProfileIDs, pt.LLMChainRevision, pt.LLMFailoverState, pt.LLMFailoverReason)
}
s.m.mu.Unlock()
}
func (s *Server) emitTaskLLMTransition(t *Task, transition db.TaskLLMTransition, cause error) {
if t == nil {
return
}
previous := s.llmAuditProfile(transition.PreviousProfileID)
var next *llmAuditProfile
if transition.NextProfileID != nil {
next = s.llmAuditProfile(*transition.NextProfileID)
}
mode := "automatic"
kind := "llm_switch"
summary := fmt.Sprintf("%s 额度不足", llmAuditProfileLabel(previous))
if transition.NextProfileID != nil {
summary += fmt.Sprintf(",后续调用切换到 %s", llmAuditProfileLabel(next))
} else {
mode = "exhausted"
kind = "llm_failover"
summary += ",配置链已耗尽"
}
metadata, _ := json.Marshal(llmActivityMetadata{LLMTransition: llmTransitionAudit{
Mode: mode, Reason: cause.Error(), Previous: previous, Next: next,
}})
s.engine.emitActivity(t, db.Activity{Worker: "system", Kind: kind, IsError: transition.ChainExhausted, Summary: summary, Detail: cause.Error(), Metadata: metadata})
log.Printf("[llm-failover] task %s: %s", t.ID, summary)
}
func (s *Server) llmAuditProfile(id int64) *llmAuditProfile {
if id <= 0 || s.m == nil || s.m.pg == nil {
return nil
}
p, err := s.m.pg.ProfileByID(id)
if err != nil || p == nil {
return &llmAuditProfile{ID: id, Name: fmt.Sprintf("配置 #%d", id)}
}
return &llmAuditProfile{ID: p.ID, Name: p.Name, Format: p.Format, Model: p.Model}
}
func llmAuditProfileLabel(profile *llmAuditProfile) string {
if profile == nil {
return "默认配置"
}
name := profile.Name
if name == "" {
name = fmt.Sprintf("配置 #%d", profile.ID)
}
detail := []string{}
if profile.Format != "" {
detail = append(detail, profile.Format)
}
if profile.Model != "" {
detail = append(detail, profile.Model)
}
if len(detail) == 0 {
return name
}
return fmt.Sprintf("%s(%s)", name, strings.Join(detail, " / "))
}
func sameOptionalID(a, b *int64) bool {
if a == nil || b == nil {
return a == nil && b == nil
}
return *a == *b
}
func (s *Server) emitManualTaskLLMSwitch(t *Task, previousID, nextID *int64) db.Activity {
previous := (*llmAuditProfile)(nil)
next := (*llmAuditProfile)(nil)
if previousID != nil {
previous = s.llmAuditProfile(*previousID)
}
if nextID != nil {
next = s.llmAuditProfile(*nextID)
}
summary := fmt.Sprintf("用户手动将任务 LLM 从 %s 切换到 %s", llmAuditProfileLabel(previous), llmAuditProfileLabel(next))
metadata, _ := json.Marshal(llmActivityMetadata{LLMTransition: llmTransitionAudit{
Mode: "manual", Reason: "用户手动切换任务 LLM", Previous: previous, Next: next,
}})
return s.engine.emitActivity(t, db.Activity{Worker: "system", Kind: "llm_switch", Summary: summary, Detail: summary, Metadata: metadata})
}