feat(roundtable): 第二/三步模型自动容错(出问题自动换下一个模型)
模型出问题时按 config.yaml models[] 顺序自动切到下一个模型继续,并告知用户 「『X』模型异常 → 已切换『Y』继续」。前台实时 + 后台挂起两条路径都覆盖。 - 中间件兜底/熔断 AIMessage 打 LLM_ERROR_MARKER 标记,is_llm_error_message 可识别 - roundtable_model_fallback: fallback_model_chain 候选链 + reason_text + 总开关 env ROUNDTABLE_MODEL_FALLBACK - 触发: 异常(start_run失败/idle卡死) / 兜底错误文案 / 交付为空(仅seat/leader) - 后台 InProcessRoundtableGateway._run_turn_with_fallback 包住每轮 run - 前台 _special_run/_leader_run 收尾检测失败→model_switch 帧+换模型重流 - 结构化抽取 flow_extract round1 astream 同款换模型循环 - Step2 单席位异常隔离: 单席位失败不拖垮整批/整链/整个作业 - 时间线插「系统提示」气泡 + toast + 诊断 model_switch 事件 - Tests: test_roundtable_model_fallback.py Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
ae2706dad3
commit
d383b0e9dd
18 files changed
+1033
-174
No files matched your search
@@ -101,6 +101,11 @@ export interface FlowExtractRequest {
|
||||
onValidating?: () => void;
|
||||
/** 校验/修复流式增量(同样含思考 + 正文,前端实时显示校验过程)。 */
|
||||
onValidateDelta?: (text: string) => void;
|
||||
/**
|
||||
* 模型自动容错:后端某模型抽取失败,已切到下一个模型重抽。上层据此**清空已累计的抽取增量**
|
||||
* (`onExtractDelta` 之前吐出的半截内容)并提示用户。`failedModel`/`nextModel`/`reason` 描述切换。
|
||||
*/
|
||||
onModelSwitch?: (info: { failedModel: string; nextModel: string; reason: string }) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -161,6 +166,12 @@ export async function streamFlowExtract(req: FlowExtractRequest): Promise<FlowGr
|
||||
} else if (event === "validate_delta") {
|
||||
const c = typeof frame.content === "string" ? frame.content : "";
|
||||
if (c) req.onValidateDelta?.(c);
|
||||
} else if (event === "model_switch") {
|
||||
req.onModelSwitch?.({
|
||||
failedModel: typeof frame.failed_model === "string" ? frame.failed_model : "",
|
||||
nextModel: typeof frame.next_model === "string" ? frame.next_model : "",
|
||||
reason: typeof frame.reason === "string" ? frame.reason : "",
|
||||
});
|
||||
} else if (event === "final") {
|
||||
graph = (frame.graph as FlowGraphData) ?? null;
|
||||
} else if (event === "error") {
|
||||
|
||||
@@ -297,6 +297,27 @@ export interface StreamMultiAgentRequest {
|
||||
name?: string,
|
||||
args?: Record<string, unknown>,
|
||||
) => void;
|
||||
/**
|
||||
* 模型自动容错:后端检测到当前模型出问题(兜底错误文案 / 交付为空等),已自动切到下一个
|
||||
* 模型并即将重跑本轮时触发。上层据此把该角色「正在流式的失败气泡」重置(清空已累计的增量
|
||||
* 文本)并给用户一条「『X』模型异常,已切换『Y』继续」的提示。此回调可能多次触发(连续多个
|
||||
* 模型都失败时)。触发后,本次 streamMultiAgent 仍会继续用新模型流式,最终照常 resolve。
|
||||
*/
|
||||
onModelSwitch?: (info: ModelSwitchInfo) => void;
|
||||
}
|
||||
|
||||
/** 模型自动容错通知(`streamMultiAgent` 的 `onModelSwitch` 回调入参)。 */
|
||||
export interface ModelSwitchInfo {
|
||||
/** 角色:leader / seat / seat_parallel / report / summary / dashboard / ingest。 */
|
||||
role: string;
|
||||
/** 出问题的智能体 agent_name。 */
|
||||
agentName: string;
|
||||
/** 失败的模型名。 */
|
||||
failedModel: string;
|
||||
/** 已切换到的下一个模型名。 */
|
||||
nextModel: string;
|
||||
/** 失败原因(已翻译成简短中文,如「服务繁忙」)。 */
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface ClarificationPayload {
|
||||
@@ -330,6 +351,8 @@ export interface StreamMultiAgentResult {
|
||||
content: string;
|
||||
/** Echoed agent_name of the agent that produced this run. */
|
||||
agentName: string | null;
|
||||
/** 实际产出本轮结果的模型(发生模型自动容错时即最终切换到的模型);后端未下发时为 null。 */
|
||||
modelUsed?: string | null;
|
||||
/**
|
||||
* Populated when the leader called ask_clarification — the run ended without
|
||||
* dispatching, and the model is asking the user a question instead. When
|
||||
@@ -555,6 +578,7 @@ export async function streamMultiAgent(
|
||||
content: "",
|
||||
agentName: null,
|
||||
clarification: null,
|
||||
modelUsed: null,
|
||||
};
|
||||
|
||||
// Inferred SSE phase + a tiny helper so we only fire onPhaseChange when the
|
||||
@@ -680,6 +704,28 @@ export async function streamMultiAgent(
|
||||
});
|
||||
}
|
||||
|
||||
// 模型自动容错帧:后端检测到当前模型出问题,已切到下一个模型并即将重跑本轮。
|
||||
// 不是终态帧(无 content)→ 不结束流。清空已累计的(失败尝试的)tool_call 增量,
|
||||
// 把 phase 退回 connected,并通知上层重置该角色气泡 + 给用户提示。随后新模型的
|
||||
// 流式增量会照常通过 onTextDelta 继续累计到(已被上层清空的)气泡。
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === "object" &&
|
||||
(parsed as Record<string, unknown>).status === "model_switch"
|
||||
) {
|
||||
const obj = parsed as Record<string, unknown>;
|
||||
toolCallAcc.clear();
|
||||
currentPhase = "connected";
|
||||
req.onModelSwitch?.({
|
||||
role: typeof obj.role === "string" ? obj.role : "",
|
||||
agentName: typeof obj.agent_name === "string" ? obj.agent_name : req.agentName,
|
||||
failedModel: typeof obj.failed_model === "string" ? obj.failed_model : "",
|
||||
nextModel: typeof obj.next_model === "string" ? obj.next_model : "",
|
||||
reason: typeof obj.reason === "string" ? obj.reason : "",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Phase inference from LangGraph messages-tuple frames. We only flip
|
||||
// to tool_calling/tool_result on chunks that clearly indicate them;
|
||||
// everything else with non-empty AI text means we're streaming.
|
||||
@@ -780,6 +826,10 @@ export async function streamMultiAgent(
|
||||
if (typeof parsed.agent_name === "string") {
|
||||
result.agentName = parsed.agent_name;
|
||||
}
|
||||
{
|
||||
const mu = (parsed as Record<string, unknown>).model_used;
|
||||
if (typeof mu === "string" && mu) result.modelUsed = mu;
|
||||
}
|
||||
// SSE error 帧(后端 stream_generator 在席位/Step3 run 失败时下发
|
||||
// `{status:"error", content, agent_name}`):上报,便于收集用户侧报错。
|
||||
if (result.message === "error") {
|
||||
|
||||
@@ -20,6 +20,7 @@ import { useArtifacts } from "@/components/workspace/artifacts";
|
||||
import {
|
||||
initMultiAgent,
|
||||
streamMultiAgent,
|
||||
type ModelSwitchInfo,
|
||||
type ClarificationPayload,
|
||||
type LeaderDispatch,
|
||||
type StreamPhase,
|
||||
@@ -1204,6 +1205,24 @@ export function useStep2Orchestration(
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* 模型自动容错通知(前台):后端切到下一个模型重跑本轮时调用。弹 toast + 在时间线插一条
|
||||
* 「系统提示」气泡,让用户看到「『X』模型异常 → 已切换『Y』继续」。
|
||||
*/
|
||||
const notifyModelSwitch = useCallback(
|
||||
(info: ModelSwitchInfo) => {
|
||||
const who = info.agentName || "智能体";
|
||||
triggerToast(`⚠️「${info.failedModel}」模型${info.reason},已自动切换「${info.nextModel}」继续`);
|
||||
appendDialogue({
|
||||
sender: "系统提示",
|
||||
avatarType: "amber",
|
||||
content: `⚠️ 模型容错:「${who}」所用模型「${info.failedModel}」${info.reason},已自动切换到「${info.nextModel}」继续。`,
|
||||
confidence: "模型容错",
|
||||
});
|
||||
},
|
||||
[appendDialogue, triggerToast],
|
||||
);
|
||||
|
||||
const phaseLabel = useCallback(
|
||||
(
|
||||
phase: StreamPhase,
|
||||
@@ -1319,6 +1338,20 @@ export function useStep2Orchestration(
|
||||
);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 生成某气泡的 onModelSwitch 处理器:弹提示 + 把该气泡里「失败尝试已累计的半截内容」清空
|
||||
* (随后新模型的流式增量会重新填进同一气泡),并把状态标为「模型切换中…」。
|
||||
*/
|
||||
const makeModelSwitchHandler = useCallback(
|
||||
(bubbleId: number, isParallel: boolean) => (info: ModelSwitchInfo) => {
|
||||
notifyModelSwitch(info);
|
||||
if (isParallel) parallelStreamRef.current.delete(bubbleId);
|
||||
setDialogueContent(bubbleId, "");
|
||||
setDialogueConfidence(bubbleId, "模型切换中…");
|
||||
},
|
||||
[notifyModelSwitch, setDialogueContent, setDialogueConfidence],
|
||||
);
|
||||
|
||||
// ─── §6 并行流式:全接收 / 惰性渲染 / 节流 ───────────────────────────────
|
||||
/**
|
||||
* 节流 flush(~80ms 一拍,约 12fps):**仅被选中**的席位把 ref 累积的全文挂到
|
||||
@@ -1650,6 +1683,7 @@ export function useStep2Orchestration(
|
||||
// agent 自身开关取 OR。
|
||||
seatSkillDirective: seatSkillDirectivesRef.current[a.agentId] ?? false,
|
||||
signal: subAborter.signal,
|
||||
onModelSwitch: makeModelSwitchHandler(a.bubbleId, a.isParallel),
|
||||
onTextDelta: (text) =>
|
||||
a.isParallel ? ingestParallelDelta(a.bubbleId, text) : appendToDialogue(a.bubbleId, text),
|
||||
onPhaseChange: (phase, toolName, args) => {
|
||||
@@ -1739,6 +1773,7 @@ export function useStep2Orchestration(
|
||||
files: takeOnce(pendingInjectFilesRef),
|
||||
model: selectedModelRef.current || undefined,
|
||||
signal: leaderAborter.signal,
|
||||
onModelSwitch: makeModelSwitchHandler(leaderBubbleId, false),
|
||||
onTextDelta: (text) => appendToDialogue(leaderBubbleId, text),
|
||||
onPhaseChange: (phase, toolName, args) => {
|
||||
const label = phaseLabel(phase, "leader", toolName);
|
||||
@@ -2014,6 +2049,7 @@ export function useStep2Orchestration(
|
||||
files: takeOnce(pendingInjectFilesRef),
|
||||
model: selectedModelRef.current || undefined,
|
||||
signal: leaderAborter.signal,
|
||||
onModelSwitch: makeModelSwitchHandler(leaderBubbleId, false),
|
||||
onTextDelta: (text) => appendToDialogue(leaderBubbleId, text),
|
||||
onPhaseChange: (phase, toolName, args) => {
|
||||
const label = phaseLabel(phase, "leader", toolName);
|
||||
@@ -2095,6 +2131,7 @@ export function useStep2Orchestration(
|
||||
// agent 自身开关取 OR。
|
||||
seatSkillDirective: seatSkillDirectivesRef.current[seat.agent_id] ?? false,
|
||||
signal: subAborter.signal,
|
||||
onModelSwitch: makeModelSwitchHandler(subBubbleId, false),
|
||||
onTextDelta: (text) => appendToDialogue(subBubbleId, text),
|
||||
onPhaseChange: (phase, toolName, args) => {
|
||||
const label = phaseLabel(phase, "special", toolName);
|
||||
@@ -2158,6 +2195,7 @@ export function useStep2Orchestration(
|
||||
suppressPreBroadcast: true,
|
||||
model: selectedModelRef.current || undefined,
|
||||
signal: closeoutAborter.signal,
|
||||
onModelSwitch: makeModelSwitchHandler(closeoutBubbleId, false),
|
||||
onTextDelta: (text) => appendToDialogue(closeoutBubbleId, text),
|
||||
onPhaseChange: (phase, toolName, args) => {
|
||||
const label = phaseLabel(phase, "leader", toolName);
|
||||
@@ -2319,6 +2357,7 @@ export function useStep2Orchestration(
|
||||
files: takeOnce(pendingInjectFilesRef),
|
||||
model: selectedModelRef.current || undefined,
|
||||
signal: leaderAborter.signal,
|
||||
onModelSwitch: makeModelSwitchHandler(leaderBubbleId, false),
|
||||
onTextDelta: (text) => appendToDialogue(leaderBubbleId, text),
|
||||
onPhaseChange: (phase, toolName, args) => {
|
||||
const label = phaseLabel(phase, "leader", toolName);
|
||||
@@ -2444,6 +2483,7 @@ export function useStep2Orchestration(
|
||||
signal: subAborter.signal,
|
||||
// §6:并行席位走 ref 全量累积 + 节流 flush(只渲染选中的);单节点
|
||||
// stage 实际串行,1 路流直接 setState 不卡,沿用 appendToDialogue。
|
||||
onModelSwitch: makeModelSwitchHandler(bubbleId, isParallel),
|
||||
onTextDelta: (text) =>
|
||||
isParallel ? ingestParallelDelta(bubbleId, text) : appendToDialogue(bubbleId, text),
|
||||
onPhaseChange: (phase, toolName, args) => {
|
||||
@@ -2624,6 +2664,7 @@ export function useStep2Orchestration(
|
||||
...seatModeToRequestFields(seatModesRef.current[agentId] ?? selectedModeRef.current),
|
||||
seatSkillDirective: seatSkillDirectivesRef.current[agentId] ?? false,
|
||||
signal: subAborter.signal,
|
||||
onModelSwitch: makeModelSwitchHandler(bubbleId, true),
|
||||
onTextDelta: (text) => ingestParallelDelta(bubbleId, text),
|
||||
onPhaseChange: (phase, toolName, args) => {
|
||||
const label = phaseLabel(phase, "special", toolName);
|
||||
|
||||
@@ -325,6 +325,12 @@ export function useStep3Dashboard(options: UseStep3DashboardOptions): UseStep3Da
|
||||
newMessage: message,
|
||||
model: selectedModel || undefined,
|
||||
signal: aborter.signal,
|
||||
// 模型自动容错:后端切到下一个模型重跑 → 清空失败尝试的半截累计(acc + 气泡)。
|
||||
onModelSwitch: (info) => {
|
||||
acc = "";
|
||||
setDialogues((prev) => prev.map((d) => (d.id === bubbleId ? { ...d, content: "" } : d)));
|
||||
setDialogueConfidence(bubbleId, `模型切换:${info.failedModel}→${info.nextModel}(${info.reason})`);
|
||||
},
|
||||
onTextDelta: (text) => {
|
||||
appendToDialogue(bubbleId, text);
|
||||
acc += text;
|
||||
|
||||
@@ -770,6 +770,16 @@ export function useStep3Report(
|
||||
newMessage: prompt,
|
||||
model: selectedModel || undefined,
|
||||
signal: aborter.signal,
|
||||
// 模型自动容错:后端切到下一个模型重跑 → 清空失败尝试半截正文 + 步骤卡留可见提示。
|
||||
onModelSwitch: (info) => {
|
||||
setDialogues((prev) => prev.map((d) => (d.id === bubbleId ? { ...d, content: "" } : d)));
|
||||
setDialogueConfidence(bubbleId, `模型切换:${info.failedModel}→${info.nextModel}`);
|
||||
appendStepIfNew(
|
||||
bubbleId,
|
||||
`⚠️ 模型容错:「${info.failedModel}」${info.reason},改用「${info.nextModel}」`,
|
||||
`model_switch:${info.nextModel}`,
|
||||
);
|
||||
},
|
||||
onTextDelta: (text) => appendToDialogue(bubbleId, text),
|
||||
onPhaseChange: (p, toolName, toolArgs) => {
|
||||
const label = reportPhaseLabel(p, toolName);
|
||||
|
||||
@@ -1059,6 +1059,16 @@ export function useStep3Summary(
|
||||
model: selectedModel || undefined,
|
||||
files: files && files.length > 0 ? files : undefined,
|
||||
signal: aborter.signal,
|
||||
// 模型自动容错:后端切到下一个模型重跑 → 清空失败尝试的半截正文 + 在步骤卡留一条可见提示。
|
||||
onModelSwitch: (info) => {
|
||||
setDialogues((prev) => prev.map((d) => (d.id === bubbleId ? { ...d, content: "" } : d)));
|
||||
setDialogueConfidence(bubbleId, `模型切换:${info.failedModel}→${info.nextModel}`);
|
||||
appendStepIfNew(
|
||||
bubbleId,
|
||||
`⚠️ 模型容错:「${info.failedModel}」${info.reason},改用「${info.nextModel}」`,
|
||||
`model_switch:${info.nextModel}`,
|
||||
);
|
||||
},
|
||||
onTextDelta: (text) => appendToDialogue(bubbleId, text),
|
||||
onPhaseChange: (p, toolName, pArgs) => {
|
||||
const label = summaryPhaseLabel(p, toolName);
|
||||
@@ -1160,6 +1170,16 @@ export function useStep3Summary(
|
||||
currentGraph: opts?.currentGraph ?? null,
|
||||
instruction: opts?.instruction,
|
||||
signal: aborter.signal,
|
||||
// 模型自动容错:后端切到下一个模型重抽 → 清空抽取气泡里失败尝试的半截内容 + 留可见提示。
|
||||
onModelSwitch: (info) => {
|
||||
setDialogues((prev) => prev.map((d) => (d.id === extractBubbleId ? { ...d, content: "" } : d)));
|
||||
setDialogueConfidence(extractBubbleId, `模型切换:${info.failedModel}→${info.nextModel}`);
|
||||
appendStepIfNew(
|
||||
extractBubbleId,
|
||||
`⚠️ 模型容错:「${info.failedModel}」${info.reason},改用「${info.nextModel}」`,
|
||||
`model_switch:${info.nextModel}`,
|
||||
);
|
||||
},
|
||||
onExtractDelta: (t) => appendToDialogue(extractBubbleId, t),
|
||||
onValidating: () => {
|
||||
setFlowPhase("validating");
|
||||
|
||||
@@ -66,6 +66,10 @@ function toStep2Dialogue(d: JobDialogue, idxByAgent: Map<string, number>): Step2
|
||||
if (d.role === "leader") {
|
||||
return { ...base, sender: "总控协调", avatarType: "blue", confidence: "派活说明" };
|
||||
}
|
||||
// 模型自动容错的系统提示气泡(后端 progress.model_switch 以 role="system" 落库)。
|
||||
if (d.role === "system") {
|
||||
return { ...base, sender: d.name || "系统提示", avatarType: "amber", confidence: "模型容错" };
|
||||
}
|
||||
const aid = d.agentId ?? "";
|
||||
return {
|
||||
...base,
|
||||
|
||||
@@ -52,7 +52,8 @@ export interface DispatchChainNode {
|
||||
/** 一条研讨对话记录(leader 说明 / 某席位交付)。 */
|
||||
export interface JobDialogue {
|
||||
id: number;
|
||||
role: "leader" | "seat";
|
||||
// "system" = 模型自动容错等系统提示气泡(后端 progress.model_switch 落库)。
|
||||
role: "leader" | "seat" | "system";
|
||||
agentId: string | null;
|
||||
name: string;
|
||||
content: string;
|
||||
|
||||
Reference in new issue
Block a user