feat(llm): 五层重试的次数与间隔可配

一次模型调用的失败由内到外经过五层重试:建连(SDK)、空响应(SDK)、同 provider
安全窗口、轮询熔断、意图重跑。此前五层的次数与间隔全是写死的常量,遇到脾气各异
的中转站只能改代码。现在都能在 LLM 页配置,不配就是原来的默认值。

配置语义五层统一(db.RetryRule):次数 0=内置默认 / -1=关掉这层 / >0=该值;
间隔 0=原本的指数退避 / >0=改用固定间隔。0 被"未配置"占用,故关闭用 -1。
写库前夹紧到 次数 [-1,20]、间隔 [0,1h],DB 另有 CHECK 兜底。

粒度按层的性质分:前三层跟着端点走,可在每个模型配置里逐字段覆盖全局默认(只钉
间隔的仍继承全局次数);熔断与意图重跑是进程级语义,只有全局一份。

生效路径:①②是 provider 构建参数,保存后重建缓存;③随每次 hooks.current()
解析,故障转移换配置时重试节奏跟着换;④在失败路径上每次都要读,由 applyRetryPolicy
推给 llmpool.Registry;⑤每条意图收尾读一次。全部热生效,无需重启。

连接测试刻意不带这些参数——它有 30s 硬超时,叠上用户配的重试只会把本来能用的
端点测成超时失败。

norma 升 v0.3.5(llm.Config 新增 RetryInterval / EmptyResponseRetries /
EmptyResponseInterval)。旧库升级后行为逐字节不变:新列默认 0,settings 键不
存在即全默认。设计见 docs/LLM重试设计.md。
This commit is contained in:
Autumn-27 committed 2026-09-09 13:35:04 -04:00
1 parent 377ccc7e5a
commit a50bf02428
21 files changed
+1216 -146

No files matched your search

@@ -0,0 +1,310 @@
"use client";
// LLM 重试配置的共用件:五层重试各自的「次数 + 间隔」。
//
// 五层从内到外:建连(SDK) → 空响应(SDK) → 同 provider 安全窗口 → 轮询熔断 → 意图重跑。
// 前三层跟着端点走,所以每个模型配置都能覆盖全局默认;后两层是进程级的,只有全局一份。
//
// 所有输入都遵循同一套「留空 = 不配置」语义,与后端 db.RetryRule 一致:
// 次数 空/0 = 用内置默认 | -1 = 关闭这层重试 | >0 = 用这个次数
// 间隔 空/0 = 用这层原本的指数退避 | >0 = 改用这个固定毫秒间隔
import * as React from "react";
import { Loader2Icon, SaveIcon } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api } from "@/lib/api";
import type { LLMRetryOverride, LLMRetryPolicy, LLMRetryRule } from "@/lib/types";
export const ZERO_RULE: LLMRetryRule = { attempts: 0, interval_ms: 0 };
export const ZERO_OVERRIDE: LLMRetryOverride = {
connect: ZERO_RULE,
empty: ZERO_RULE,
stream: ZERO_RULE,
};
const ZERO_POLICY: LLMRetryPolicy = {
...ZERO_OVERRIDE,
breaker: ZERO_RULE,
intent: ZERO_RULE,
};
type LayerMeta = {
title: string;
/** 这层重试发生在哪、由谁执行 */
where: string;
desc: string;
attemptsLabel: string;
/** 次数留空时的默认值,用于占位符 */
defAttempts: number;
/** 间隔留空时的默认策略,用于占位符 */
defInterval: string;
/** 次数填 -1 的含义 */
offHint: string;
};
export const RETRY_LAYERS = {
connect: {
title: "建连重试",
where: "SDK · 拿到 200 之前",
desc: "连接重置 / 超时 / 429 / 5xx 等在流开始前的失败,原样重发请求。流一旦开始,中途断流不归这层管。",
attemptsLabel: "重试次数",
defAttempts: 3,
defInterval: "0.5s→1s→2s 指数(封顶 8s)",
offHint: "-1 = 一次都不重试,失败立刻上抛",
},
empty: {
title: "空响应重试",
where: "SDK · 仅 openai 格式",
desc: "请求正常完成、但一个内容块都没有(正常 stop 且无输出)时重发整个 prompt。max_tokens 截断不算,不会重试。",
attemptsLabel: "重试次数",
defAttempts: 2,
defInterval: "0.5s→1s→2s 指数(封顶 8s)",
offHint: "-1 = 空响应直接原样交出",
},
stream: {
title: "同 provider 安全窗口重试",
where: "本项目 · 未交付输出前",
desc: "流中途断开、供应商过载、流内 429——只要一个 token 都还没交给调用方,就在同一个配置上重放,不会重复模型输出或工具执行。额度耗尽、上下文过长、4xx 确定性拒绝不重试。",
attemptsLabel: "重试次数",
defAttempts: 2,
defInterval: "0.5s→1s 指数(封顶 4s)",
offHint: "-1 = 断流直接交给外层的意图重跑",
},
breaker: {
title: "轮询熔断",
where: "本项目 · 进程级,全局一份",
desc: "某个配置连续失败到阈值就进入冷却,冷却期内轮询直接跳过它。余额不足 / 密钥失效这类确定性失败不看阈值,第一次就熔断。",
attemptsLabel: "连续失败几次熔断",
defAttempts: 3,
defInterval: "1min→5min→30min 梯度",
offHint: "-1 = 瞬时失败永不熔断(确定性失败仍然熔断)",
},
intent: {
title: "意图重跑",
where: "本项目 · 进程级,全局一份",
desc: "worker 以 model_error 收场(provider 故障,内层重试全都用尽)时,整条意图重跑。重跑期间任务被暂停 / 终止 / 进入收尾则立即让位。",
attemptsLabel: "重跑次数",
defAttempts: 2,
defInterval: "固定 3s",
offHint: "-1 = 不重跑,该意图直接判为 blocked",
},
} satisfies Record<string, LayerMeta>;
type LayerKey = keyof typeof RETRY_LAYERS;
/** 毫秒的人话,只用于在输入框旁边回显,免得数零。 */
function humanMs(ms: number) {
if (!Number.isFinite(ms) || ms <= 0) return "";
if (ms < 1000) return `${ms}ms`;
if (ms < 60_000) return `${Number((ms / 1000).toFixed(2))}s`;
return `${Number((ms / 60_000).toFixed(2))}min`;
}
/** 受控数字输入:空串 ↔ 0,中间态("-"、"1e")原样留在本地,不打扰父级。 */
function NumField({
id,
value,
onChange,
placeholder,
min,
}: {
id: string;
value: number;
onChange: (n: number) => void;
placeholder: string;
min: number;
}) {
const [text, setText] = React.useState(value === 0 ? "" : String(value));
// 父级换了一整套值(读取到策略、切换配置)时跟上;自己敲字时不会走到这里,
// 因为那时 value 已经等于本地文本 parse 后的结果。
React.useEffect(() => {
const incoming = value === 0 ? "" : String(value);
setText((cur) => (Number(cur || 0) === value ? cur : incoming));
}, [value]);
return (
<Input
id={id}
type="number"
min={min}
className="w-28 shrink-0"
value={text}
placeholder={placeholder}
onChange={(e) => {
setText(e.target.value);
const n = Number(e.target.value);
onChange(e.target.value.trim() === "" || !Number.isFinite(n) ? 0 : Math.trunc(n));
}}
/>
);
}
/** 一层重试的两个旋钮。idPrefix 用来在同一页出现多次时保住 label 的 htmlFor。 */
export function RetryRuleFields({
layer,
idPrefix,
value,
onChange,
compact,
}: {
layer: LayerKey;
idPrefix: string;
value: LLMRetryRule;
onChange: (r: LLMRetryRule) => void;
/** true = 配置抽屉里的紧凑版:只留标题与两个输入 */
compact?: boolean;
}) {
const meta = RETRY_LAYERS[layer];
const human = humanMs(value.interval_ms);
return (
<div className={compact ? "grid gap-2" : "grid gap-3 rounded-lg border p-3"}>
<div className="grid gap-0.5">
<div className="flex flex-wrap items-baseline gap-2">
<Label className="text-sm">{meta.title}</Label>
<span className="text-muted-foreground text-xs">{meta.where}</span>
</div>
{!compact && <p className="text-muted-foreground text-xs">{meta.desc}</p>}
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<div className="flex items-center gap-2">
<Label htmlFor={`${idPrefix}-${layer}-n`} className="text-muted-foreground text-xs">
{meta.attemptsLabel}
</Label>
<NumField
id={`${idPrefix}-${layer}-n`}
min={-1}
value={value.attempts}
placeholder={`默认 ${meta.defAttempts}`}
onChange={(n) => onChange({ ...value, attempts: n })}
/>
</div>
<div className="flex items-center gap-2">
<Label htmlFor={`${idPrefix}-${layer}-ms`} className="text-muted-foreground text-xs">
间隔 ms
</Label>
<NumField
id={`${idPrefix}-${layer}-ms`}
min={0}
value={value.interval_ms}
placeholder="默认退避"
onChange={(n) => onChange({ ...value, interval_ms: n })}
/>
<span className="text-muted-foreground text-xs">{human ? `固定 ${human}` : meta.defInterval}</span>
</div>
</div>
{!compact && <p className="text-muted-foreground text-xs">留空 = 用默认;{meta.offHint}。</p>}
</div>
);
}
/** 模型配置抽屉里的三层覆盖(跟着端点走的那三层)。 */
export function ProfileRetryFields({
value,
onChange,
}: {
value: LLMRetryOverride;
onChange: (o: LLMRetryOverride) => void;
}) {
return (
<div className="grid gap-3 rounded-lg border p-3">
<div className="grid gap-0.5">
<Label className="text-sm">重试覆盖</Label>
<p className="text-muted-foreground text-xs">
只对这个配置生效,覆盖「重试与退避」里的全局默认。每格留空 = 跟随全局;次数填 -1 = 关掉这层重试;
间隔填了就用固定间隔取代指数退避。熔断与意图重跑是进程级的,只能在全局那页调。
</p>
</div>
{(["connect", "empty", "stream"] as const).map((k) => (
<div key={k} className="border-t pt-3 first:border-t-0 first:pt-0">
<RetryRuleFields
compact
layer={k}
idPrefix="pf"
value={value[k]}
onChange={(r) => onChange({ ...value, [k]: r })}
/>
</div>
))}
</div>
);
}
/** 「重试与退避」tab:五层的全局默认值。 */
export function RetryPolicyPanel() {
const [policy, setPolicy] = React.useState<LLMRetryPolicy>(ZERO_POLICY);
const [loading, setLoading] = React.useState(true);
const [saving, setSaving] = React.useState(false);
const load = React.useCallback(async () => {
setLoading(true);
try {
const p = await api.llmRetryPolicy();
setPolicy({ ...ZERO_POLICY, ...p });
} catch (e) {
toast.error(`读取重试策略失败:${(e as Error).message}`);
} finally {
setLoading(false);
}
}, []);
React.useEffect(() => {
void load();
}, [load]);
async function save() {
if (saving) return;
setSaving(true);
try {
// 后端会把越界值夹回区间并回传,直接用回传值刷新,所见即所存。
const saved = await api.saveLLMRetryPolicy(policy);
setPolicy({ ...ZERO_POLICY, ...saved });
toast.success("已保存,即时生效(正在跑的这一轮调用仍用旧参数)");
} catch (e) {
toast.error(`保存失败:${(e as Error).message}`);
} finally {
setSaving(false);
}
}
const set = (k: LayerKey) => (r: LLMRetryRule) => setPolicy((p) => ({ ...p, [k]: r }));
if (loading) {
return (
<div className="flex items-center gap-2 rounded-lg border border-dashed p-10 text-muted-foreground text-sm">
<Loader2Icon className="size-4 animate-spin" /> 读取重试策略…
</div>
);
}
return (
<div className="grid gap-4">
<div className="rounded-lg border bg-muted/30 p-3 text-muted-foreground text-xs leading-relaxed">
一次模型调用的失败会依次经过五层重试,由内到外:
<span className="text-foreground"> 建连 → 空响应 → 同 provider 安全窗口 → 轮询熔断 → 意图重跑</span>
。内层用尽才轮到外层,所以次数是
<span className="text-foreground">相乘</span>
的——把每层都拉满,一次抖动能烧掉几十次请求。
全部留空即当前默认值,与没有这页时的行为完全一致。前三层可以在每个模型配置里单独覆盖。
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{(Object.keys(RETRY_LAYERS) as LayerKey[]).map((k) => (
<RetryRuleFields key={k} layer={k} idPrefix="gl" value={policy[k]} onChange={set(k)} />
))}
</div>
<div className="flex gap-2">
<Button onClick={save} disabled={saving}>
{saving ? <Loader2Icon className="animate-spin" /> : <SaveIcon />}
保存
</Button>
<Button variant="outline" onClick={() => setPolicy(ZERO_POLICY)} disabled={saving}>
全部恢复默认
</Button>
</div>
</div>
);
}
+115 -94
View File
@@ -25,10 +25,13 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Separator } from "@/components/ui/separator";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { api } from "@/lib/api";
import type { LLMPoolMember, LLMPoolStatus, LLMProfile } from "@/lib/types";
import type { LLMPoolMember, LLMPoolStatus, LLMProfile, LLMRetryOverride } from "@/lib/types";
import { cn } from "@/lib/utils";
import { ProfileRetryFields, RetryPolicyPanel, ZERO_OVERRIDE } from "./_components/retry";
// 思考开关(thinking.type)与思考强度(reasoning_effort)是两个【互相独立】的字段,
// 各自单独设置——有些接口没有 thinking 字段、只靠强度参数就能激活思考,故需解耦。
// 存库空字符串 = 该字段【不发送】;Radix Select 不接受空 value,故 UI 用 "none"
@@ -325,6 +328,7 @@ function ProfileSheet({
const [maxTokens, setMaxTokens] = React.useState("0"); // 单次回复输出上限;0=不发送
const [maxTokensField, setMaxTokensField] = React.useState(NONE); // 上限用哪个字段名;NONE=max_tokens
const [sessionHeaderKey, setSessionHeaderKey] = React.useState(""); // 自定义会话头名;空=不发送
const [retry, setRetry] = React.useState<LLMRetryOverride>(ZERO_OVERRIDE); // 本配置的重试覆盖;全 0=跟随全局
const [testing, setTesting] = React.useState(false);
const [saving, setSaving] = React.useState(false);
const [models, setModels] = React.useState<string[]>([]);
@@ -336,9 +340,7 @@ function ProfileSheet({
React.useEffect(() => {
if (!open) return;
setName(profile?.name ?? "");
setFormat(
profile?.format === "openai" || profile?.format === "openai-responses" ? profile.format : "anthropic",
);
setFormat(profile?.format === "openai" || profile?.format === "openai-responses" ? profile.format : "anthropic");
setModel(profile?.model ?? "");
setBaseUrl(profile?.base_url ?? "");
setProxy(profile?.proxy ?? "");
@@ -353,6 +355,7 @@ function ProfileSheet({
setMaxTokens(String(profile?.max_tokens ?? 0));
setMaxTokensField(fromStore(profile?.max_tokens_field));
setSessionHeaderKey(profile?.session_header_key ?? "");
setRetry(profile?.retry ?? ZERO_OVERRIDE);
setApiKey("");
setKeyHint(profile?.api_key_hint ?? "");
setModels([]);
@@ -440,6 +443,7 @@ function ProfileSheet({
// 后端也会再做一次同样的归一化,这里只是别让 UI 送出自相矛盾的值。
max_tokens_field: format === "openai" ? toStore(maxTokensField) : "",
session_header_key: sessionHeaderKey.trim(),
retry,
});
if (isNew) toast.success(`已新建:${name.trim()}(在卡片上「设为激活」以启用)`);
else toast.success(profile?.is_default ? "已保存,激活配置即时生效,无需重启" : "已保存");
@@ -583,9 +587,9 @@ function ProfileSheet({
onChange={(e) => setSessionHeaderKey(e.target.value)}
/>
<p className="text-muted-foreground text-xs">
填写头名后,每次请求都会带上这个 HTTP 头,头值自动填为{" "}
<b>当前会话的 session id</b>(chat 会话如 conv-12、worker 如 exp3-worker-i87)。用于按 session-id
头做提示缓存 / 粘性路由的网关;同一会话多轮稳定、不同会话互不相同。留空则不发送。
填写头名后,每次请求都会带上这个 HTTP 头,头值自动填为 <b>当前会话的 session id</b>(chat 会话如
conv-12、worker 如 exp3-worker-i87)。用于按 session-id 头做提示缓存 /
粘性路由的网关;同一会话多轮稳定、不同会话互不相同。留空则不发送。
</p>
</div>
@@ -758,6 +762,8 @@ function ProfileSheet({
</Select>
</div>
</div>
<ProfileRetryFields value={retry} onChange={setRetry} />
</div>
<div className="flex gap-2 border-t px-4 py-3">
@@ -869,97 +875,112 @@ export default function LLMPage() {
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{profiles.map((p) => {
const h = healthOf(p, health.get(p.id));
return (
// biome-ignore lint/a11y/useSemanticElements: 卡片内含自己的操作按钮,用原生 <button> 会造成按钮嵌套(非法 HTML)
<Card
key={p.id}
role="button"
tabIndex={0}
onClick={() => openEditor(p)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
openEditor(p);
}
}}
className={cn(
"cursor-pointer gap-0 py-4 outline-none transition-colors hover:border-foreground/30",
p.is_default && "border-amber-400/50 bg-amber-400/5",
)}
>
<CardContent className="grid gap-2 px-4">
<div className="flex items-start gap-2">
<StarIcon
className={cn(
"mt-0.5 size-4 shrink-0",
p.is_default ? "fill-amber-400 text-amber-400" : "text-muted-foreground",
)}
/>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate font-medium text-sm">{p.name}</span>
<Badge variant="outline" className="uppercase">
{p.format}
</Badge>
<Badge variant="outline" className={cn("ml-auto", h.cls)} title={h.hint}>
{h.label}
</Badge>
<Tabs defaultValue="profiles" className="flex-1">
<TabsList>
<TabsTrigger value="profiles">模型配置</TabsTrigger>
<TabsTrigger value="retry">重试与退避</TabsTrigger>
</TabsList>
<TabsContent value="profiles" className="mt-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{profiles.map((p) => {
const h = healthOf(p, health.get(p.id));
return (
// biome-ignore lint/a11y/useSemanticElements: 卡片内含自己的操作按钮,用原生 <button> 会造成按钮嵌套(非法 HTML)
<Card
key={p.id}
role="button"
tabIndex={0}
onClick={() => openEditor(p)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
openEditor(p);
}
}}
className={cn(
"cursor-pointer gap-0 py-4 outline-none transition-colors hover:border-foreground/30",
p.is_default && "border-amber-400/50 bg-amber-400/5",
)}
>
<CardContent className="grid gap-2 px-4">
<div className="flex items-start gap-2">
<StarIcon
className={cn(
"mt-0.5 size-4 shrink-0",
p.is_default ? "fill-amber-400 text-amber-400" : "text-muted-foreground",
)}
/>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate font-medium text-sm">{p.name}</span>
<Badge variant="outline" className="uppercase">
{p.format}
</Badge>
<Badge variant="outline" className={cn("ml-auto", h.cls)} title={h.hint}>
{h.label}
</Badge>
</div>
<code className="mt-1 block truncate font-mono text-muted-foreground text-xs">{p.model}</code>
</div>
</div>
<code className="mt-1 block truncate font-mono text-muted-foreground text-xs">{p.model}</code>
</div>
</div>
<div className="flex flex-wrap gap-x-3 gap-y-0.5 pl-6 text-muted-foreground text-xs">
{p.api_key_hint && <span>{p.api_key_hint}</span>}
<span>
{p.rate_per_second}/s · {p.rate_per_minute}/min
</span>
{p.proxy && <span className="truncate">代理 {p.proxy}</span>}
{p.reasoning_effort && <span>思考 {p.reasoning_effort === "off" ? "关" : p.reasoning_effort}</span>}
{/* 轮询相关的两个字段只在轮询开着时才有意义,关着时不占版面 */}
{poolOn &&
!p.is_default &&
(p.pool_exclude ? <span>不参与轮询</span> : <span>优先级 {p.priority ?? 0}</span>)}
</div>
<div className="flex flex-wrap gap-x-3 gap-y-0.5 pl-6 text-muted-foreground text-xs">
{p.api_key_hint && <span>{p.api_key_hint}</span>}
<span>
{p.rate_per_second}/s · {p.rate_per_minute}/min
</span>
{p.proxy && <span className="truncate">代理 {p.proxy}</span>}
{p.reasoning_effort && (
<span>思考 {p.reasoning_effort === "off" ? "关" : p.reasoning_effort}</span>
)}
{/* 轮询相关的两个字段只在轮询开着时才有意义,关着时不占版面 */}
{poolOn &&
!p.is_default &&
(p.pool_exclude ? <span>不参与轮询</span> : <span>优先级 {p.priority ?? 0}</span>)}
</div>
<div className="mt-1 flex gap-2">
<Button
size="sm"
variant="outline"
className="flex-1"
disabled={p.is_default}
onClick={(e) => {
e.stopPropagation();
void activate(p.id, p.name);
}}
>
{p.is_default ? "已激活" : "设为激活"}
</Button>
<Button
size="icon"
variant="outline"
aria-label="删除配置"
onClick={(e) => {
e.stopPropagation();
void remove(p);
}}
>
<Trash2Icon className="text-destructive" />
</Button>
</div>
</CardContent>
</Card>
);
})}
{profiles.length === 0 && (
<div className="col-span-full rounded-lg border border-dashed p-10 text-center text-muted-foreground text-sm">
还没有模型配置,点击右上角「新建」创建第一个。
<div className="mt-1 flex gap-2">
<Button
size="sm"
variant="outline"
className="flex-1"
disabled={p.is_default}
onClick={(e) => {
e.stopPropagation();
void activate(p.id, p.name);
}}
>
{p.is_default ? "已激活" : "设为激活"}
</Button>
<Button
size="icon"
variant="outline"
aria-label="删除配置"
onClick={(e) => {
e.stopPropagation();
void remove(p);
}}
>
<Trash2Icon className="text-destructive" />
</Button>
</div>
</CardContent>
</Card>
);
})}
{profiles.length === 0 && (
<div className="col-span-full rounded-lg border border-dashed p-10 text-center text-muted-foreground text-sm">
还没有模型配置,点击右上角「新建」创建第一个。
</div>
)}
</div>
)}
</div>
</TabsContent>
<TabsContent value="retry" className="mt-4">
<RetryPolicyPanel />
</TabsContent>
</Tabs>
<ProfileSheet profile={editing} open={editOpen} onOpenChange={setEditOpen} onSaved={() => void load()} />
<PoolSheet open={poolOpen} onOpenChange={setPoolOpen} pool={pool} onReload={loadPool} />
+6
View File
@@ -45,6 +45,8 @@ import type {
LLMProfile,
LLMRecordDetail,
LLMRecordItem,
LLMRetryOverride,
LLMRetryPolicy,
LLMTask,
MCPServer,
MCPTool,
@@ -724,6 +726,7 @@ export const api = {
max_tokens?: number; // 单次回复输出上限;0=不发送,由服务端默认值决定
max_tokens_field?: string; // ""=max_tokens(默认) | "max_completion_tokens"(仅 openai 格式)
session_header_key?: string; // 非空=每次请求带该 HTTP 头,头值=当前会话 session id;""=不发送
retry?: LLMRetryOverride; // 本配置的重试覆盖;各项留 0 = 跟随全局重试策略
}) => 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) }),
@@ -731,6 +734,9 @@ export const api = {
llmPool: () => get<LLMPoolStatus>("/llm/pool"),
// 清除熔断,让下一次调用立刻重试该配置;不传 id = 全部清除。
resetLLMPool: (id?: string) => post<LLMPoolStatus>("/llm/pool/reset", { id: id ? Number(id) : 0 }),
// 全局重试策略(五层各自的次数+间隔)。全 0 = 全部走内置默认。
llmRetryPolicy: () => get<LLMRetryPolicy>("/llm/retry-policy"),
saveLLMRetryPolicy: (p: LLMRetryPolicy) => post<LLMRetryPolicy>("/llm/retry-policy", p),
fetchLLMModels: (provider: string, base_url: string, api_key: string, proxy = "", profile_id?: number) =>
post<{ ok: boolean; error?: string; models?: string[] }>("/llm/models", {
provider,
+26
View File
@@ -916,6 +916,32 @@ export interface LLMProfile {
// 自定义会话头名:非空时每次请求带该 HTTP 头,头值=当前会话/意图的 session id。
// ""=不发送。用于按 session-id 头做提示缓存/粘性路由的网关。
session_header_key?: string;
// 本配置对重试的覆盖(建连/空响应/同 provider 安全窗口)。留空/全 0 = 跟随全局策略。
retry?: LLMRetryOverride;
}
// ---- LLM 重试策略 ----
// 一层重试的两个旋钮。两者都是「0 = 未配置」:
// attempts 0=用默认次数 | -1=关闭该层重试 | >0=重试次数
// interval_ms 0=用默认的指数退避 | >0=改用这个固定毫秒间隔
export interface LLMRetryRule {
attempts: number;
interval_ms: number;
}
// 单个 LLM 配置能覆盖的三层(都是「跟着端点走」的重试)。
export interface LLMRetryOverride {
connect: LLMRetryRule; // 建连重试:连接重置/超时/429/5xx,流开始前
empty: LLMRetryRule; // 空响应重试:完成但没有任何内容(仅 openai 格式)
stream: LLMRetryRule; // 同 provider 安全窗口重试:未交付输出前的断流重放
}
// 全局策略 = 上面三层的默认值 + 两层只有全局的:
// breaker 轮询熔断(attempts=连续几次瞬时失败熔断,interval_ms=固定冷却时长)
// intent 意图重跑(worker 以 model_error 收场后整条意图重跑)
export interface LLMRetryPolicy extends LLMRetryOverride {
breaker: LLMRetryRule;
intent: LLMRetryRule;
}
// ---- LLM 轮询(故障转移)----