feat(llm): 模型配置新增输出上限,支持 max_completion_tokens 字段名

llm_profiles 加两列:max_tokens(单次回复输出上限,0=不发送该字段,由服务端
默认值决定)与 max_tokens_field(上限发哪个键)。后者仅对 format=openai 生效:
空(默认)= max_tokens;'max_completion_tokens' = 新键。OpenAI 推理模型
(o 系列/GPT-5)拒收 max_tokens、只认新键,而多数兼容网关反过来只认旧键,
故做成显式开关而非自动推断。anthropic 与 openai-responses 自带字段名,
存下来只会误导,保存时一并清空。

顺带补上一处从未接通的链路:planner/worker/chat/mainagent/goals 五个
agentcore.Options 都没设过 MaxTokens,恒为 0——openai 系因 omitempty 压根
不发上限,anthropic 走 norma 的 8192 兜底。光加配置项而不接这条线,用户在
后台填了数字也不会生效,故本次一并接通,取值与 streaming 一样按 profile
每轮解析,故障转移切换后下一轮即生效。

task_llm.go 里 nonStreaming() 的来源优先级(agent 绑定 → 任务链激活 profile
→ 全局)抽成共用的 activeCfg(),避免 maxTokens() 再抄一份同样的三级回退。

norma 升到 v0.3.3(该版本提供 Config.MaxTokensField)。go.mod 只动 norma 这
一行:仓库此前未 tidy 过,跑 go mod tidy 会顺带升 x/crypto、x/net 等一批无关
依赖,不混进本次改动。

迁移:新列走 ADD COLUMN IF NOT EXISTS + 默认 0/'',旧库升级后行为完全不变;
CHECK 约束限定取值,每次启动先删后建保持幂等。
This commit is contained in:
Autumn-27 committed 2026-08-31 12:20:31 -04:00
1 parent 338e77c064
commit 7cc467c159
20 files changed
+364 -33

No files matched your search

+6
View File
@@ -583,6 +583,10 @@ type LLMProfileDTO struct {
// 收发模式:true=流式(SSE) | false=非流式。没有 omitempty —— false 必须出现在
// 响应里,否则前端读不到「非流式」,开关会回落成默认的流式。
Streaming bool `json:"streaming"`
// 单次回复输出上限(0=不发送,由服务端默认值决定),以及它用哪个请求字段名
// (''=max_tokens | 'max_completion_tokens',仅 openai 格式有意义)。
MaxTokens int `json:"max_tokens"`
MaxTokensField string `json:"max_tokens_field"`
}
func llmProfileDTO(p *db.LLMProfile) LLMProfileDTO {
@@ -603,6 +607,8 @@ func llmProfileDTO(p *db.LLMProfile) LLMProfileDTO {
Priority: p.Priority,
PoolExclude: p.PoolExclude,
Streaming: p.Streaming,
MaxTokens: p.MaxTokens,
MaxTokensField: p.MaxTokensField,
}
}
+1 -1
View File
@@ -384,7 +384,7 @@ 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)
goalRuntime := s.agentsForTask(t).runtime
decomposed := agent.DecomposeGoalsWithProvider(ctx, goalRuntime, s.m.dir, t.Goal, t.Description, as, t.Store, taskID, goalRuntime.nonStreaming(), emit)
decomposed := agent.DecomposeGoalsWithProvider(ctx, goalRuntime, s.m.dir, t.Goal, t.Description, as, t.Store, taskID, goalRuntime.nonStreaming(), goalRuntime.maxTokens(), emit)
s.engine.EndLLMCall(t.ID)
for _, g := range decomposed {
if strings.TrimSpace(g.Text) != "" {
+20 -3
View File
@@ -312,6 +312,7 @@ func (s *Server) loadLLMConfig() (agent.Config, bool) {
cfg.ThinkingType = p.ThinkingType
cfg.ReasoningEffort = p.ReasoningEffort
cfg.Stream = p.Streaming
cfg.MaxTokens, cfg.MaxTokensField = p.MaxTokens, p.MaxTokensField
if cfg.APIKey == "" {
return cfg, false
}
@@ -327,16 +328,19 @@ func (s *Server) saveLLMConfig(cfg agent.Config) error {
// (anthropic / openai / openai-responses), matching the DB CHECK constraint.
format := cfg.Provider()
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,
// pool_exclude, and streaming to zero values on every save.
// 这个 legacy 端点的请求体不含轮询/收发/输出上限参数,故把库里已存的值原样带回 —
// 否则每次保存都会把 profile 的 priority、pool_exclude、streaming 以及输出上限
// (max_tokens / max_tokens_field)悄悄重置成零值。
var priority int
var poolExclude bool
streaming := true // 旧库/新建默认流式
var maxTokens int
var maxTokensField string
if profs, _ := s.m.pg.ListProfiles(); profs != nil {
for _, p := range profs {
if p.Name == "default" {
id, priority, poolExclude, streaming = p.ID, p.Priority, p.PoolExclude, p.Streaming
maxTokens, maxTokensField = p.MaxTokens, p.MaxTokensField
break
}
}
@@ -346,6 +350,7 @@ func (s *Server) saveLLMConfig(cfg agent.Config) error {
APIKey: cfg.APIKey, RatePerSecond: cfg.RatePerSecond, RatePerMinute: cfg.RatePerMinute,
ContextWindowK: cfg.ContextWindowK, ThinkingType: cfg.ThinkingType, ReasoningEffort: cfg.ReasoningEffort, IsDefault: true,
Priority: priority, PoolExclude: poolExclude, Streaming: streaming,
MaxTokens: maxTokens, MaxTokensField: maxTokensField,
})
if err != nil {
return err
@@ -396,6 +401,7 @@ func (s *Server) buildPlannerWorker(pinID *int64, gProv llm.Provider, gCfg agent
wk.SetWebSearch(s.webSearchFor("worker"))
wk.SetConstraintInject(s.constraintInjectWorker) // 操作约束注入 worker(可配置,默认开;每轮读)
wk.SetNonStreaming(nonStreamingResolver(wCfg)) // 该 profile 选非流式时走 Provider.Complete
wk.SetMaxTokens(maxTokensResolver(wCfg)) // 单次回复输出上限(0 = 不发)
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
@@ -404,6 +410,7 @@ func (s *Server) buildPlannerWorker(pinID *int64, gProv llm.Provider, gCfg agent
pl.SetWebSearch(s.webSearchFor("planner"))
pl.SetConstraintInject(s.constraintInjectPlanner) // 操作约束注入 planner(可配置,默认开;每轮读)
pl.SetNonStreaming(nonStreamingResolver(pCfg)) // 该 profile 选非流式时走 Provider.Complete
pl.SetMaxTokens(maxTokensResolver(pCfg)) // 单次回复输出上限(0 = 不发)
return pl, wk
}
@@ -415,6 +422,12 @@ func nonStreamingResolver(cfg agent.Config) func() bool {
return func() bool { return nonStreaming }
}
// maxTokensResolver mirrors nonStreamingResolver for the per-reply output cap.
func maxTokensResolver(cfg agent.Config) func() int {
maxTokens := cfg.MaxTokens
return func() int { return maxTokens }
}
// 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 {
@@ -459,6 +472,7 @@ func (s *Server) applyLLM(cfg agent.Config) error {
s.mainAgent.SetWebSearch(s.webSearchFor("mainagent"))
s.mainAgent.SetSteerWork(s.engine.SteerWork) // steer_work:人对运行中 work 实时纠偏
s.mainAgent.SetNonStreaming(nonStreamingResolver(mCfg))
s.mainAgent.SetMaxTokens(maxTokensResolver(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
@@ -466,6 +480,7 @@ func (s *Server) applyLLM(cfg agent.Config) error {
s.chatAgent.SetWebSearch(s.m.WebSearchOpts())
s.chatAgent.SetGuard(s.chatGuard())
s.chatAgent.SetNonStreaming(nonStreamingResolver(cfg))
s.chatAgent.SetMaxTokens(maxTokensResolver(cfg))
s.llmProv = prov
s.llmCfg = cfg
s.llmOn = true
@@ -492,6 +507,7 @@ func (s *Server) loadProfileConfig(id int64) (agent.Config, bool) {
cfg.ThinkingType = p.ThinkingType
cfg.ReasoningEffort = p.ReasoningEffort
cfg.Stream = p.Streaming
cfg.MaxTokens, cfg.MaxTokensField = p.MaxTokens, p.MaxTokensField
if cfg.APIKey == "" {
return cfg, false
}
@@ -644,6 +660,7 @@ func (s *Server) chatAgentForProfile(id int64) *agent.ChatAgent {
ca.SetWebSearch(s.m.WebSearchOpts())
ca.SetGuard(s.chatGuard())
ca.SetNonStreaming(nonStreamingResolver(cfg))
ca.SetMaxTokens(maxTokensResolver(cfg))
s.profMu.Lock()
if ex := s.profChatAgents[id]; ex != nil { // lost the race → keep the winner
ca = ex
+10
View File
@@ -25,6 +25,7 @@ import (
"github.com/Autumn-27/artex/agent"
"github.com/Autumn-27/artex/db"
"github.com/Autumn-27/norma/llm"
"github.com/Autumn-27/norma/skill"
)
@@ -1804,6 +1805,15 @@ func (s *Server) pgSaveProfile(w http.ResponseWriter, r *http.Request) {
p := body.LLMProfile
p.APIKey = body.APIKey
p.Streaming = body.Streaming == nil || *body.Streaming
// 输出上限:负数无意义,归零(= 不发送该字段)。字段名开关只有 Chat Completions
// 用得上——anthropic 与 openai-responses 各自定死了字段名,存下来只会误导后续读者,
// 故非 openai 格式一律清空。未知取值同样清空,避免把 DB CHECK 的报错甩给用户。
if p.MaxTokens < 0 {
p.MaxTokens = 0
}
if p.Format != "openai" || p.MaxTokensField != llm.MaxTokensFieldCompletion {
p.MaxTokensField = ""
}
id, err := pg.SaveProfile(&p)
if err != nil {
writeErr(w, 500, err.Error())
+29 -14
View File
@@ -139,34 +139,46 @@ 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 {
// activeCfg resolves the task's currently-active LLM config, mirroring current()'s
// source precedence (agent binding → active chain profile → global). Read-only and
// best-effort: ok=false when nothing resolves, leaving the per-setting fallback to
// the caller. If failover switches profiles, the change takes effect on the next
// agent run (a fresh Session is built per run in captureRun).
func (r *taskLLMRuntime) activeCfg() (agent.Config, bool) {
taskNum, err := parseTaskID(r.taskID)
if err != nil {
return false
return agent.Config{}, false
}
pt, err := r.s.m.pg.GetTask(taskNum)
if err != nil || pt == nil {
return false
return agent.Config{}, false
}
if _, cfg, ok := r.s.agentBindingProvider(r.agentKey); ok {
return !cfg.Stream
return cfg, true
}
if len(pt.LLMProfileIDs) > 0 && pt.ActiveLLMProfileID != nil {
if _, cfg, ok := r.s.providerForProfile(*pt.ActiveLLMProfileID); ok {
return !cfg.Stream
return cfg, true
}
}
if _, cfg, ok := r.s.globalProvider(); ok {
return !cfg.Stream
return cfg, true
}
return false
return agent.Config{}, false
}
// nonStreaming reports whether the task's currently-active LLM source is set to
// non-streaming. Unresolvable → streaming (false), the safe default.
func (r *taskLLMRuntime) nonStreaming() bool {
cfg, ok := r.activeCfg()
return ok && !cfg.Stream
}
// maxTokens returns the currently-active source's per-reply output cap.
// Unresolvable → 0, i.e. send no cap, matching the pre-setting behaviour.
func (r *taskLLMRuntime) maxTokens() int {
cfg, _ := r.activeCfg() // 未解析出配置时是零值 0
return cfg.MaxTokens
}
func parseTaskID(id string) (int64, error) {
@@ -531,6 +543,7 @@ func (s *Server) agentsForTask(t *Task) *taskAgentBundle {
wk := agent.NewWorker(workerRuntime, "task-router", s.m.dir, tx, window, s.agentMaxTurns("worker"))
wk.SetCompactionWindowResolver(workerRuntime.CompactionWindow)
wk.SetNonStreaming(workerRuntime.nonStreaming) // 按任务当前激活 profile 的流式开关(每轮读)
wk.SetMaxTokens(workerRuntime.maxTokens) // 同上,输出上限也跟随当前激活 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")))
@@ -539,6 +552,7 @@ func (s *Server) agentsForTask(t *Task) *taskAgentBundle {
pl := agent.NewPlanner(plannerRuntime, "task-router", s.m.dir, tx, plannerRuntime.CompactionWindow(), s.agentMaxTurns("planner"))
pl.SetCompactionWindowResolver(plannerRuntime.CompactionWindow)
pl.SetNonStreaming(plannerRuntime.nonStreaming)
pl.SetMaxTokens(plannerRuntime.maxTokens)
pl.SetKillWork(s.engine.KillWork)
pl.SetSteerWork(s.engine.SteerWork)
pl.SetProxy(s.m.ProxyAddr(), s.m.ProxyCACert())
@@ -547,6 +561,7 @@ func (s *Server) agentsForTask(t *Task) *taskAgentBundle {
main := agent.NewMainAgent(mainRuntime, "task-router", s.m.dir, tx, mainRuntime.CompactionWindow(), s.agentMaxTurns("mainagent"))
main.SetCompactionWindowResolver(mainRuntime.CompactionWindow)
main.SetNonStreaming(mainRuntime.nonStreaming)
main.SetMaxTokens(mainRuntime.maxTokens)
main.SetProxy(s.m.ProxyAddr(), s.m.ProxyCACert())
main.SetWebSearch(s.webSearchFor("mainagent"))
main.SetSteerWork(s.engine.SteerWork) // steer_work:人对运行中 work 实时纠偏