feat(llm): 每个 LLM 配置支持流式/非流式切换(默认流式)

依赖 norma v0.3.0 的 Provider.Complete。为每个 llm_profile 增加 streaming
开关:开(默认)走流式 SSE;关走真·非流式(stream:false、一次性返回完整
JSON),可绕开部分网关糟糕的 SSE 实现(空帧、思考字段丢帧),代价是失去
运行中的实时进度与实时 token 计数。

- agent.Config.Stream → agentcore.Options.NonStreaming = !Stream;worker /
  planner / mainagent / chat / goals 经 SetNonStreaming resolver 注入,任务
  路由按当前激活 profile 动态取值
- llmpool.Pool / llmrec.Recorder / taskLLMRuntime 三个 Provider 包装均实现
  Complete(分别带故障转移 / 录制 / 同 provider 重试与额度 failover),使
  输出、token 统计、操作统计、LLM 录制四条链路在非流式下保持兼容
- db: llm_profiles 新增 streaming 列 + ALTER 补旧库(默认 true,旧配置无感)
- 系统 → LLM 配置表单增加「流式输出」开关
This commit is contained in:
Autumn-27 committed 2026-08-25 10:49:47 -04:00
1 parent 19a7206d12
commit 587ba422b4
22 files changed
+409 -75

No files matched your search

+2 -1
View File
@@ -383,7 +383,8 @@ func (s *Server) createGoals(ctx context.Context, t *Task, emit func(db.Activity
}
taskID, _ := strconv.ParseInt(t.ID, 10, 64)
s.engine.BeginLLMCall(t.ID)
decomposed := agent.DecomposeGoalsWithProvider(ctx, s.agentsForTask(t).runtime, s.m.dir, t.Goal, t.Description, as, t.Store, taskID, emit)
goalRuntime := s.agentsForTask(t).runtime
decomposed := agent.DecomposeGoalsWithProvider(ctx, goalRuntime, s.m.dir, t.Goal, t.Description, as, t.Store, taskID, goalRuntime.nonStreaming(), emit)
s.engine.EndLLMCall(t.ID)
for _, g := range decomposed {
if strings.TrimSpace(g.Text) != "" {
+20 -4
View File
@@ -305,6 +305,7 @@ func (s *Server) loadLLMConfig() (agent.Config, bool) {
cfg.ContextWindowK = p.ContextWindowK
cfg.ThinkingType = p.ThinkingType
cfg.ReasoningEffort = p.ReasoningEffort
cfg.Stream = p.Streaming
if cfg.APIKey == "" {
return cfg, false
}
@@ -322,14 +323,15 @@ func (s *Server) saveLLMConfig(cfg agent.Config) error {
}
var id int64
// agent.Config carries no failover fields, so carry the stored ones forward —
// otherwise this legacy endpoint would silently reset the profile's priority
// and pool_exclude to zero values on every save.
// otherwise this legacy endpoint would silently reset the profile's priority,
// pool_exclude, and streaming to zero values on every save.
var priority int
var poolExclude bool
streaming := true // 旧库/新建默认流式
if profs, _ := s.m.pg.ListProfiles(); profs != nil {
for _, p := range profs {
if p.Name == "default" {
id, priority, poolExclude = p.ID, p.Priority, p.PoolExclude
id, priority, poolExclude, streaming = p.ID, p.Priority, p.PoolExclude, p.Streaming
break
}
}
@@ -338,7 +340,7 @@ func (s *Server) saveLLMConfig(cfg agent.Config) error {
ID: id, Name: "default", Format: format, Model: cfg.Model, BaseURL: cfg.BaseURL, Proxy: cfg.Proxy,
APIKey: cfg.APIKey, RatePerSecond: cfg.RatePerSecond, RatePerMinute: cfg.RatePerMinute,
ContextWindowK: cfg.ContextWindowK, ThinkingType: cfg.ThinkingType, ReasoningEffort: cfg.ReasoningEffort, IsDefault: true,
Priority: priority, PoolExclude: poolExclude,
Priority: priority, PoolExclude: poolExclude, Streaming: streaming,
})
if err != nil {
return err
@@ -388,6 +390,7 @@ func (s *Server) buildPlannerWorker(pinID *int64, gProv llm.Provider, gCfg agent
wk.SetMemory(memory.NewStore(filepath.Join(s.m.dir, "memory")))
wk.SetWebSearch(s.webSearchFor("worker"))
wk.SetConstraintInject(s.constraintInjectWorker) // 操作约束注入 worker(可配置,默认开;每轮读)
wk.SetNonStreaming(nonStreamingResolver(wCfg)) // 该 profile 选非流式时走 Provider.Complete
pProv, pCfg := s.providerForAgent("planner", pinID, gProv, gCfg)
pl := agent.NewPlanner(pProv, pCfg.Model, s.m.dir, tx, pCfg.CompactionWindow(), s.agentMaxTurns("planner"))
pl.SetKillWork(s.engine.KillWork) // planner kill_work → terminate a running work
@@ -395,9 +398,18 @@ func (s *Server) buildPlannerWorker(pinID *int64, gProv llm.Provider, gCfg agent
pl.SetProxy(s.m.ProxyAddr(), s.m.ProxyCACert()) // WebFetch through the recording proxy
pl.SetWebSearch(s.webSearchFor("planner"))
pl.SetConstraintInject(s.constraintInjectPlanner) // 操作约束注入 planner(可配置,默认开;每轮读)
pl.SetNonStreaming(nonStreamingResolver(pCfg)) // 该 profile 选非流式时走 Provider.Complete
return pl, wk
}
// nonStreamingResolver returns a resolver capturing a profile's streaming choice.
// The agents read it per run; a profile change rebuilds the agents (applyLLM),
// so the captured value is always the one in effect for this build.
func nonStreamingResolver(cfg agent.Config) func() bool {
nonStreaming := !cfg.Stream
return func() bool { return nonStreaming }
}
// applyLLM (re)builds the planner/worker/main-agent from cfg and installs them on
// the running engine as the GLOBAL active pair. Safe to call at runtime (UI configures LLM).
func (s *Server) applyLLM(cfg agent.Config) error {
@@ -441,12 +453,14 @@ func (s *Server) applyLLM(cfg agent.Config) error {
s.mainAgent.SetProxy(s.m.ProxyAddr(), s.m.ProxyCACert()) // WebFetch through the recording proxy
s.mainAgent.SetWebSearch(s.webSearchFor("mainagent"))
s.mainAgent.SetSteerWork(s.engine.SteerWork) // steer_work:人对运行中 work 实时纠偏
s.mainAgent.SetNonStreaming(nonStreamingResolver(mCfg))
// chat agent serves MANY custom agents by key → it holds the GLOBAL opts
// (backend/key) and gates Enabled per-conversation-agent at Chat time. 对话始终用激活配置。
s.chatAgent = agent.NewChatAgent(prov, cfg.Model, s.m.dir, tx, win) // chat page runner
s.chatAgent.SetProxy(s.m.ProxyAddr(), s.m.ProxyCACert())
s.chatAgent.SetWebSearch(s.m.WebSearchOpts())
s.chatAgent.SetGuard(s.chatGuard())
s.chatAgent.SetNonStreaming(nonStreamingResolver(cfg))
s.llmProv = prov
s.llmCfg = cfg
s.llmOn = true
@@ -472,6 +486,7 @@ func (s *Server) loadProfileConfig(id int64) (agent.Config, bool) {
cfg.ContextWindowK = p.ContextWindowK
cfg.ThinkingType = p.ThinkingType
cfg.ReasoningEffort = p.ReasoningEffort
cfg.Stream = p.Streaming
if cfg.APIKey == "" {
return cfg, false
}
@@ -623,6 +638,7 @@ func (s *Server) chatAgentForProfile(id int64) *agent.ChatAgent {
ca.SetProxy(s.m.ProxyAddr(), s.m.ProxyCACert())
ca.SetWebSearch(s.m.WebSearchOpts())
ca.SetGuard(s.chatGuard())
ca.SetNonStreaming(nonStreamingResolver(cfg))
s.profMu.Lock()
if ex := s.profChatAgents[id]; ex != nil { // lost the race → keep the winner
ca = ex
+7 -2
View File
@@ -1736,10 +1736,14 @@ func (s *Server) pgSaveProfile(w http.ResponseWriter, r *http.Request) {
return
}
// APIKey has json:"-" on db.LLMProfile so it is never leaked to the UI on
// read; accept it here via a sibling field for create/update.
// read; accept it here via a sibling field for create/update. Streaming is a
// *bool shadowing the embedded json:"streaming": an absent field must default
// to streaming (true), which a plain bool zero value (false = non-streaming)
// would get wrong — legacy/partial clients that never send it stay streaming.
var body struct {
db.LLMProfile
APIKey string `json:"api_key"`
APIKey string `json:"api_key"`
Streaming *bool `json:"streaming"`
}
if err := decode(r, &body); err != nil {
writeErr(w, 400, err.Error())
@@ -1747,6 +1751,7 @@ func (s *Server) pgSaveProfile(w http.ResponseWriter, r *http.Request) {
}
p := body.LLMProfile
p.APIKey = body.APIKey
p.Streaming = body.Streaming == nil || *body.Streaming
id, err := pg.SaveProfile(&p)
if err != nil {
writeErr(w, 500, err.Error())
+102 -4
View File
@@ -139,6 +139,36 @@ func (r *taskLLMRuntime) current() (*Task, int64, int64, llm.Provider, error) {
return t, 0, pt.LLMChainRevision, prov, nil
}
// nonStreaming reports whether the task's currently-active LLM source is set to
// non-streaming. It mirrors current()'s source precedence (agent binding →
// active chain profile → global) but reads only the streaming flag. Read-only
// and best-effort: any resolution error falls back to streaming (false), the
// safe default. If failover switches to a profile with a different streaming
// setting, the change takes effect on the next agent run (a fresh Session is
// built per run in captureRun).
func (r *taskLLMRuntime) nonStreaming() bool {
taskNum, err := parseTaskID(r.taskID)
if err != nil {
return false
}
pt, err := r.s.m.pg.GetTask(taskNum)
if err != nil || pt == nil {
return false
}
if _, cfg, ok := r.s.agentBindingProvider(r.agentKey); ok {
return !cfg.Stream
}
if len(pt.LLMProfileIDs) > 0 && pt.ActiveLLMProfileID != nil {
if _, cfg, ok := r.s.providerForProfile(*pt.ActiveLLMProfileID); ok {
return !cfg.Stream
}
}
if _, cfg, ok := r.s.globalProvider(); ok {
return !cfg.Stream
}
return false
}
func parseTaskID(id string) (int64, error) {
n, err := strconv.ParseInt(id, 10, 64)
if err != nil || n <= 0 {
@@ -147,9 +177,11 @@ func parseTaskID(id string) (int64, error) {
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{
// streamHooks builds the failover/exhaustion callbacks shared by Stream and
// Complete: how to read the current profile selection, how to mark it quota
// exhausted, and how to emit a failover transition.
func (r *taskLLMRuntime) streamHooks() taskLLMStreamHooks {
return taskLLMStreamHooks{
current: func() (taskLLMSelection, error) {
task, profileID, revision, provider, err := r.current()
return taskLLMSelection{task: task, profileID: profileID, revision: revision, provider: provider}, err
@@ -169,7 +201,70 @@ func (r *taskLLMRuntime) Stream(ctx context.Context, req llm.CompletionRequest)
r.s.emitTaskLLMTransition(selection.task, transition, cause)
},
}
return streamTaskLLM(ctx, r.taskID, req, hooks)
}
func (r *taskLLMRuntime) Stream(ctx context.Context, req llm.CompletionRequest) iter.Seq2[llm.StreamEvent, error] {
ctx = llmrec.WithTaskID(ctx, r.taskID)
return streamTaskLLM(ctx, r.taskID, req, r.streamHooks())
}
// Complete is the non-streaming counterpart of Stream. A non-streaming call is
// atomic — it never delivers partial output — so every failure is safe to retry
// on the same provider or fail over to the next profile without risking
// duplicated model output or tool execution (the "committed" bookkeeping the
// streaming path needs is unnecessary here).
func (r *taskLLMRuntime) Complete(ctx context.Context, req llm.CompletionRequest) (llm.Message, string, llm.Usage, error) {
ctx = llmrec.WithTaskID(ctx, r.taskID)
return completeTaskLLM(ctx, r.taskID, req, r.streamHooks())
}
func completeTaskLLM(ctx context.Context, taskID string, req llm.CompletionRequest, hooks taskLLMStreamHooks) (llm.Message, string, llm.Usage, error) {
for {
selection, err := hooks.current()
if err != nil {
return llm.Message{}, "", llm.Usage{}, err
}
var (
msg llm.Message
sr string
usage llm.Usage
callErr error
)
// 同 provider 安全窗口重试:非流式调用要么整体成功、要么整体失败,没有
// 中途已交付输出的问题,所以任何瞬时失败都可原样重试。
for attempt := 0; ; attempt++ {
msg, sr, usage, callErr = selection.provider.Complete(ctx, req)
if callErr != nil && ctx.Err() == nil &&
attempt < sameProviderStreamRetries && isRetryableStreamError(callErr) {
backoff := sameProviderRetryBackoff(attempt)
log.Printf("[task-llm] task %s 非流式调用失败,%v 后同 provider 重试 (%d/%d): %v",
taskID, backoff, attempt+1, sameProviderStreamRetries, callErr)
if sleepCtx(ctx, backoff) {
break // 退避期间 ctx 取消 → 停止重试
}
continue
}
break
}
if callErr == nil {
return msg, sr, usage, nil
}
// profileID=0: 显式链已被清空;非额度错误:透传。二者都不改任务 failover 状态。
if selection.profileID == 0 || !isQuotaExhaustedError(callErr) {
return llm.Message{}, "", llm.Usage{}, callErr
}
transition, markErr := hooks.exhaust(selection, callErr)
if markErr != nil {
return llm.Message{}, "", llm.Usage{}, fmt.Errorf("mark profile quota exhausted after %v: %w", callErr, markErr)
}
if transition.Advanced && !transition.Stale && hooks.transition != nil {
hooks.transition(selection, transition, callErr)
}
if !transition.Stale && transition.NextProfileID == nil {
return llm.Message{}, "", llm.Usage{}, &taskLLMError{taskID: taskID, chainExhausted: transition.ChainExhausted, cause: callErr}
}
// 没有任何输出交付给调用方,换下一个 profile 重放同一逻辑请求是安全的。
}
}
func streamTaskLLM(ctx context.Context, taskID string, req llm.CompletionRequest, hooks taskLLMStreamHooks) iter.Seq2[llm.StreamEvent, error] {
@@ -435,6 +530,7 @@ func (s *Server) agentsForTask(t *Task) *taskAgentBundle {
window := workerRuntime.CompactionWindow()
wk := agent.NewWorker(workerRuntime, "task-router", s.m.dir, tx, window, s.agentMaxTurns("worker"))
wk.SetCompactionWindowResolver(workerRuntime.CompactionWindow)
wk.SetNonStreaming(workerRuntime.nonStreaming) // 按任务当前激活 profile 的流式开关(每轮读)
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")))
@@ -442,6 +538,7 @@ func (s *Server) agentsForTask(t *Task) *taskAgentBundle {
wk.SetConstraintInject(s.constraintInjectWorker) // 操作约束注入 worker(可配置,默认开)
pl := agent.NewPlanner(plannerRuntime, "task-router", s.m.dir, tx, plannerRuntime.CompactionWindow(), s.agentMaxTurns("planner"))
pl.SetCompactionWindowResolver(plannerRuntime.CompactionWindow)
pl.SetNonStreaming(plannerRuntime.nonStreaming)
pl.SetKillWork(s.engine.KillWork)
pl.SetSteerWork(s.engine.SteerWork)
pl.SetProxy(s.m.ProxyAddr(), s.m.ProxyCACert())
@@ -449,6 +546,7 @@ func (s *Server) agentsForTask(t *Task) *taskAgentBundle {
pl.SetConstraintInject(s.constraintInjectPlanner) // 操作约束注入 planner(可配置,默认开)
main := agent.NewMainAgent(mainRuntime, "task-router", s.m.dir, tx, mainRuntime.CompactionWindow(), s.agentMaxTurns("mainagent"))
main.SetCompactionWindowResolver(mainRuntime.CompactionWindow)
main.SetNonStreaming(mainRuntime.nonStreaming)
main.SetProxy(s.m.ProxyAddr(), s.m.ProxyCACert())
main.SetWebSearch(s.webSearchFor("mainagent"))
main.SetSteerWork(s.engine.SteerWork) // steer_work:人对运行中 work 实时纠偏
+21
View File
@@ -36,6 +36,23 @@ func (p *scriptedLLMProvider) Stream(context.Context, llm.CompletionRequest) ite
}
}
func (p *scriptedLLMProvider) Complete(ctx context.Context, req llm.CompletionRequest) (llm.Message, string, llm.Usage, error) {
return accumulateStreamForTest(ctx, p.Stream, req)
}
// accumulateStreamForTest drains a mock's Stream to satisfy the non-streaming
// Complete method.
func accumulateStreamForTest(ctx context.Context, stream func(context.Context, llm.CompletionRequest) iter.Seq2[llm.StreamEvent, error], req llm.CompletionRequest) (llm.Message, string, llm.Usage, error) {
acc := llm.NewAccumulator()
for ev, err := range stream(ctx, req) {
if err != nil {
return llm.Message{}, "", llm.Usage{}, err
}
acc.Add(ev)
}
return acc.Message(), acc.StopReason, acc.Usage, nil
}
// withZeroRetryBackoff sets the same-provider retry backoff to zero for the
// duration of a (serial) test and returns a restore func for defer.
func withZeroRetryBackoff() func() {
@@ -72,6 +89,10 @@ func (p *flakyThenOKProvider) Stream(context.Context, llm.CompletionRequest) ite
}
}
func (p *flakyThenOKProvider) Complete(ctx context.Context, req llm.CompletionRequest) (llm.Message, string, llm.Usage, error) {
return accumulateStreamForTest(ctx, p.Stream, req)
}
func collectTaskLLMStream(seq iter.Seq2[llm.StreamEvent, error]) ([]llm.StreamEvent, error) {
var events []llm.StreamEvent
var streamErr error