mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-27 17:03:49 +08:00
一次模型调用的失败由内到外经过五层重试:建连(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。
311 lines
11 KiB
TypeScript
311 lines
11 KiB
TypeScript
"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>
|
||
);
|
||
}
|