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:
1 parent
338e77c064
commit
7cc467c159
20 files changed
+364
-33
No files matched your search
@@ -41,6 +41,18 @@ const THINKING_TYPES: { value: string; label: string }[] = [
|
||||
{ value: "disabled", label: "关闭" },
|
||||
{ value: "enabled", label: "开启" },
|
||||
];
|
||||
// 输出上限用哪个请求字段名(仅 openai 格式有意义)。NONE ↔ "" 走同一套哨兵转换。
|
||||
const MAX_TOKENS_FIELDS: { value: string; label: string }[] = [
|
||||
{ value: NONE, label: "max_tokens(默认)" },
|
||||
{ value: "max_completion_tokens", label: "max_completion_tokens" },
|
||||
];
|
||||
// 另外两种格式各自定死了字段名,选项对它们无意义,说明文案里直接讲清楚。
|
||||
const MAX_TOKENS_FIELD_HINTS: Record<string, string> = {
|
||||
openai:
|
||||
"上限发哪个键。max_tokens 是默认,绝大多数兼容网关只认它;OpenAI 官方推理模型(o 系列 / GPT-5)反过来只认 max_completion_tokens,收到 max_tokens 会直接报 unsupported_parameter。",
|
||||
anthropic: "仅 openai 格式可选。Anthropic 的字段名固定为 max_tokens。",
|
||||
"openai-responses": "仅 openai 格式可选。Responses API 的字段名固定为 max_output_tokens。",
|
||||
};
|
||||
const EFFORT_LEVELS: { value: string; label: string }[] = [
|
||||
{ value: NONE, label: "不发送(默认)" },
|
||||
{ value: "low", label: "low" },
|
||||
@@ -310,6 +322,8 @@ function ProfileSheet({
|
||||
const [priority, setPriority] = React.useState("0"); // 轮询顺位;越大越先
|
||||
const [poolExclude, setPoolExclude] = React.useState(false);
|
||||
const [streaming, setStreaming] = React.useState(true); // true=流式(默认);false=非流式
|
||||
const [maxTokens, setMaxTokens] = React.useState("0"); // 单次回复输出上限;0=不发送
|
||||
const [maxTokensField, setMaxTokensField] = React.useState(NONE); // 上限用哪个字段名;NONE=max_tokens
|
||||
const [testing, setTesting] = React.useState(false);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const [models, setModels] = React.useState<string[]>([]);
|
||||
@@ -335,6 +349,8 @@ function ProfileSheet({
|
||||
setPriority(String(profile?.priority ?? 0));
|
||||
setPoolExclude(profile?.pool_exclude ?? false);
|
||||
setStreaming(profile?.streaming ?? true);
|
||||
setMaxTokens(String(profile?.max_tokens ?? 0));
|
||||
setMaxTokensField(fromStore(profile?.max_tokens_field));
|
||||
setApiKey("");
|
||||
setKeyHint(profile?.api_key_hint ?? "");
|
||||
setModels([]);
|
||||
@@ -417,6 +433,10 @@ function ProfileSheet({
|
||||
priority: Number(priority) || 0,
|
||||
pool_exclude: poolExclude,
|
||||
streaming,
|
||||
max_tokens: Math.max(0, Number(maxTokens) || 0),
|
||||
// 字段名开关只对 openai(Chat Completions) 有意义,其它格式一律回落到默认;
|
||||
// 后端也会再做一次同样的归一化,这里只是别让 UI 送出自相矛盾的值。
|
||||
max_tokens_field: format === "openai" ? toStore(maxTokensField) : "",
|
||||
});
|
||||
if (isNew) toast.success(`已新建:${name.trim()}(在卡片上「设为激活」以启用)`);
|
||||
else toast.success(profile?.is_default ? "已保存,激活配置即时生效,无需重启" : "已保存");
|
||||
@@ -629,6 +649,52 @@ function ProfileSheet({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 rounded-lg border p-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="grid gap-0.5">
|
||||
<Label htmlFor="p-max-tokens" className="text-sm">
|
||||
输出上限 · max tokens
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
单次回复最多生成多少 token,随每次请求发出。0(默认)= 不发送该字段,由服务端默认值决定。
|
||||
这与上面的「上下文窗口」是两回事:那是模型总容量,只在本地用来算压缩阈值。
|
||||
设太小会让推理模型在思考阶段就被截断,一个字答案都出不来。
|
||||
</p>
|
||||
</div>
|
||||
<Input
|
||||
id="p-max-tokens"
|
||||
type="number"
|
||||
min={0}
|
||||
className="w-28 shrink-0"
|
||||
value={maxTokens}
|
||||
onChange={(e) => setMaxTokens(e.target.value)}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 border-t pt-3">
|
||||
<div className="grid gap-0.5">
|
||||
<Label className="text-sm">上限字段名</Label>
|
||||
<p className="text-muted-foreground text-xs">{MAX_TOKENS_FIELD_HINTS[format]}</p>
|
||||
</div>
|
||||
<Select
|
||||
value={format === "openai" ? maxTokensField : NONE}
|
||||
onValueChange={setMaxTokensField}
|
||||
disabled={format !== "openai"}
|
||||
>
|
||||
<SelectTrigger className="w-56 shrink-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MAX_TOKENS_FIELDS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 rounded-lg border p-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="grid gap-0.5">
|
||||
|
||||
@@ -721,6 +721,8 @@ export const api = {
|
||||
priority?: number; // 轮询顺位,越大越先用
|
||||
pool_exclude?: boolean; // true=不作为故障转移目标
|
||||
streaming?: boolean; // true(默认)=流式 | false=非流式
|
||||
max_tokens?: number; // 单次回复输出上限;0=不发送,由服务端默认值决定
|
||||
max_tokens_field?: string; // ""=max_tokens(默认) | "max_completion_tokens"(仅 openai 格式)
|
||||
}) => post<{ id: number }>("/llm/profiles", p),
|
||||
deleteLLMProfile: (id: string) => del<{ deleted: number }>(`/llm/profiles/${id}`),
|
||||
activateLLMProfile: (id: string) => post<{ ok: boolean }>("/llm/profiles/active", { id: Number(id) }),
|
||||
|
||||
@@ -3017,6 +3017,9 @@ export const llmProfiles: LLMProfile[] = [
|
||||
is_default: true,
|
||||
priority: 0,
|
||||
pool_exclude: false,
|
||||
// anthropic 的字段名固定,故 max_tokens_field 恒为空。
|
||||
max_tokens: 0,
|
||||
max_tokens_field: "",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
@@ -3033,6 +3036,9 @@ export const llmProfiles: LLMProfile[] = [
|
||||
is_default: false,
|
||||
priority: 10,
|
||||
pool_exclude: false,
|
||||
// openai 格式 + 推理模型:上限走 max_completion_tokens。
|
||||
max_tokens: 8192,
|
||||
max_tokens_field: "max_completion_tokens",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -904,6 +904,12 @@ export interface LLMProfile {
|
||||
pool_exclude?: boolean;
|
||||
// true(默认)= 流式(SSE) | false = 真·非流式(stream:false,一次性返回)。
|
||||
streaming?: boolean;
|
||||
// 单次回复的输出上限(token)。0 = 不发送该字段,由服务端默认值决定。
|
||||
// 注意与 context_window_k 区分:后者是模型总容量,只在本地用于压缩阈值。
|
||||
max_tokens?: number;
|
||||
// 上限用哪个请求字段名,仅 format="openai" 有意义:
|
||||
// ""=max_tokens(默认) | "max_completion_tokens"(OpenAI 推理模型只认它)
|
||||
max_tokens_field?: string;
|
||||
}
|
||||
|
||||
// ---- LLM 轮询(故障转移)----
|
||||
|
||||
Reference in new issue
Block a user