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

+56 -4
View File
@@ -39,10 +39,10 @@ func TaskIDFrom(ctx context.Context) string {
// Recorder wraps an llm.Provider and records every completion call.
type Recorder struct {
inner llm.Provider
pg *db.DB
model string // model name (from config, not in CompletionRequest)
prof string // LLM profile name (from llm_profiles)
inner llm.Provider
pg *db.DB
model string // model name (from config, not in CompletionRequest)
prof string // LLM profile name (from llm_profiles)
// thinkingType / reasoningEffort 是配置级思考参数(思考开关 / 思考强度)。它们在
// norma 的 buildBody() 里从 provider 配置注入真正的 HTTP body,不出现在
// CompletionRequest 上,故 Recorder 需在此单独带一份,序列化时写进录制。
@@ -185,6 +185,58 @@ func (r *Recorder) Stream(ctx context.Context, req llm.CompletionRequest) iter.S
}
}
// Complete implements llm.Provider for non-streaming calls, recording the same
// metering + body trace as Stream. Metering (token usage) is always written;
// the heavy request/response body trace is gated by the llm_record setting. The
// raw wire bodies are captured by the shared HTTP transport (via NewCapture on
// ctx), identical to the streaming path — a non-streaming response is a single
// JSON body the transport tees just the same.
func (r *Recorder) Complete(ctx context.Context, req llm.CompletionRequest) (llm.Message, string, llm.Usage, error) {
recordBodies := r.enabled == nil || r.enabled()
start := time.Now()
session := transcript.SessionIDFrom(ctx)
parsedID, worker := parseSession(session)
expID := db.ParseExpID(parsedID)
taskID := TaskIDFrom(ctx)
if taskID == "" {
taskID = parsedID
}
reqBody := ""
var capt *Capture
if recordBodies {
reqBody = r.serializeRequest(req)
ctx, capt = NewCapture(ctx)
}
msg, stopReason, usage, err := r.inner.Complete(ctx, req)
status := "ok"
if err != nil {
status = "error"
}
// Lightweight metering row — always written.
r.recordUsage(taskID, expID, worker, usage, int(time.Since(start).Milliseconds()), status)
// Heavy trace row — only when body recording is on.
if recordBodies {
text, thinking := msg.Text(), thinkingText(msg)
r.record(req, session, taskID, worker, reqBody, capt, start, text, thinking, usage, stopReason, err)
}
return msg, stopReason, usage, err
}
// thinkingText concatenates the thinking blocks of a message (the non-streaming
// counterpart of the streaming path's accumulated thinkingBuf).
func thinkingText(msg llm.Message) string {
var b strings.Builder
for _, bl := range msg.Content {
if bl.Type == llm.BlockThinking && bl.Thinking != "" {
b.WriteString(bl.Thinking)
}
}
return b.String()
}
// recordUsage appends one lightweight metering row to llm_usage (no bodies). Skips
// zero-token calls with no model, which carry nothing worth metering.
func (r *Recorder) recordUsage(taskID string, expID int64, worker string, usage llm.Usage, latencyMs int, status string) {