Files
ARTEX-chixuweihu/web/src/app/(main)/system/llm/_components/retry.tsx
T
Autumn-27 a50bf02428 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。
2026-09-09 13:35:04 -04:00

311 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}