feat(ai-writing): 各阶段模型调用自动容错,失败时换下一个模型继续
参考多智能体会商的容错思路:意图解析/大纲/正文/审核四个 LLM 环节失败时,按 config.yaml models 顺序换下一个模型重跑该环节, 并通过 model_switch 事件在前端时间线提示「『X』模型<原因>, 已自动切换到『Y』继续」。正文逐章节、审核逐维度独立切换, 已生成的章节/维度保留;意图解析成功的模型还会写回 state 供下游阶段沿用,避免每阶段重复经历同一次失败。 - nodes/_model_fallback.py: 候选模型链 + 异常分类 + model_switch 事件构造(沿用 ROUNDTABLE_MODEL_FALLBACK[_MAX] env 开关) - intent_parser/writer_outline/writer_draft/editor: 失败循环换模型 - 前端 types/reducer/timeline: 新增 model_switch 事件类型,按 stage 复位对应流式缓冲并以 amber 系统提示渲染 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
23743ee193
commit
b59cbd51d9
9 files changed
+598
-144
No files matched your search
@@ -19,6 +19,7 @@ const DISPLAYED_TYPES = new Set([
|
||||
'await_user',
|
||||
'resumed',
|
||||
'notice',
|
||||
'model_switch',
|
||||
'done',
|
||||
'error',
|
||||
]);
|
||||
|
||||
@@ -13,6 +13,7 @@ interface TimelineStepProps {
|
||||
function statusOf(event: ProgressEvent, isLast: boolean): StepStatus {
|
||||
if (event.type === 'error') return 'error';
|
||||
if (event.type === 'notice') return 'warning';
|
||||
if (event.type === 'model_switch') return 'warning';
|
||||
if (event.type === 'await_user') return 'paused';
|
||||
// step_start 只有在列表末尾(后续事件还未到达)时才显示转圈,否则已有后续事件说明该步骤已完成
|
||||
if (event.type === 'step_start') return isLast ? 'running' : 'done';
|
||||
@@ -30,8 +31,11 @@ const AGENT_COLOR: Record<string, string> = {
|
||||
|
||||
export function TimelineStep({ event, isLast, children }: TimelineStepProps) {
|
||||
const status = statusOf(event, isLast ?? true);
|
||||
// notice=非致命告警(如 ES 波动、模型兜底),error=致命错误,两者都展示「标题 + 真实原因」
|
||||
const isAlert = event.type === 'error' || event.type === 'notice';
|
||||
// notice=非致命告警(如 ES 波动、模型兜底),error=致命错误,model_switch=模型自动容错切换(amber 同 notice)
|
||||
const isAlert =
|
||||
event.type === 'error' ||
|
||||
event.type === 'notice' ||
|
||||
event.type === 'model_switch';
|
||||
const isError = event.type === 'error';
|
||||
|
||||
return (
|
||||
|
||||
@@ -388,6 +388,37 @@ function handleEvent(prev: AIWritingState, event: ProgressEvent): AIWritingState
|
||||
case 'notice':
|
||||
return base;
|
||||
|
||||
case 'model_switch': {
|
||||
// 模型自动容错:丢弃当前阶段已累积的流式缓冲,避免上一个失败模型的半截输出
|
||||
// 与下一个模型的输出粘在一起;时间线展示侧由 TimelineStep 按 amber 渲染(同 notice)。
|
||||
const stage = event.modelSwitch?.stage;
|
||||
const sectionIndex = event.modelSwitch?.sectionIndex;
|
||||
const resetByStage: Partial<AIWritingState> = (() => {
|
||||
switch (stage) {
|
||||
case 'intent':
|
||||
return { intentStreamingText: '', intentThinkingText: '' };
|
||||
case 'outline':
|
||||
return { outlineStreamingText: '', outlineThinkingText: '' };
|
||||
case 'draft': {
|
||||
// 只清掉失败那一节的流式片段,已写好的章节保持不动
|
||||
if (typeof sectionIndex === 'number') {
|
||||
return {
|
||||
streamingDraftSections: prev.streamingDraftSections.filter(
|
||||
(s) => s.sectionIndex !== sectionIndex,
|
||||
),
|
||||
};
|
||||
}
|
||||
return { streamingDraftSections: [], streamingDraftTitle: '' };
|
||||
}
|
||||
case 'review':
|
||||
return { streamingReviewItems: [] };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
})();
|
||||
return { ...base, ...resetByStage };
|
||||
}
|
||||
|
||||
default:
|
||||
return base;
|
||||
}
|
||||
@@ -506,6 +537,22 @@ export function normalizeProgressDict(parsed: Record<string, unknown>, sessionId
|
||||
out.reviewItemIssues = (out.issues as Record<string, unknown>[]).map(normalizeReviewIssue);
|
||||
}
|
||||
|
||||
// model_switch(updates 通道里的 progress_events 也可能带):折成 modelSwitch 子对象
|
||||
if (out.type === 'model_switch' && !out.modelSwitch) {
|
||||
out.modelSwitch = {
|
||||
stage: (out.stage as 'intent' | 'outline' | 'draft' | 'review') ?? 'intent',
|
||||
failedModel: String(out.failed_model ?? out.failedModel ?? ''),
|
||||
nextModel: String(out.next_model ?? out.nextModel ?? ''),
|
||||
reason: String(out.reason ?? ''),
|
||||
sectionIndex: typeof out.section_index === 'number'
|
||||
? (out.section_index as number)
|
||||
: (typeof out.sectionIndex === 'number' ? (out.sectionIndex as number) : undefined),
|
||||
sectionTitle: typeof out.section_title === 'string'
|
||||
? (out.section_title as string)
|
||||
: (typeof out.sectionTitle === 'string' ? (out.sectionTitle as string) : undefined),
|
||||
};
|
||||
}
|
||||
|
||||
if (!out.message) out.message = '';
|
||||
return out as unknown as ProgressEvent;
|
||||
}
|
||||
@@ -522,6 +569,7 @@ export function customToProgressEvent(raw: unknown, sessionId: string): Progress
|
||||
'research_progress', 'outline_chunk', 'draft_chunk',
|
||||
'review_chunk', 'review_item_done',
|
||||
'skill_search_step', 'skill_select_chunk',
|
||||
'model_switch',
|
||||
];
|
||||
if (!allowed.includes(type as ProgressEventType)) return null;
|
||||
|
||||
@@ -536,10 +584,30 @@ export function customToProgressEvent(raw: unknown, sessionId: string): Progress
|
||||
if (Array.isArray(merged.issues)) {
|
||||
merged.reviewItemIssues = (merged.issues as Record<string, unknown>[]).map(normalizeReviewIssue);
|
||||
}
|
||||
if ('agent_name' in merged && !merged.agentName) merged.agentName = merged.agent_name;
|
||||
// skill_search_step:把字段折成 skillStep 子对象,方便 reducer / UI 取用
|
||||
if (type === 'skill_search_step') {
|
||||
merged.skillStep = normalizeSkillStep(o);
|
||||
}
|
||||
// model_switch:把 snake_case 字段折成 modelSwitch 子对象(reducer + TimelineStep 取用)
|
||||
if (type === 'model_switch') {
|
||||
merged.modelSwitch = {
|
||||
stage: (o.stage as 'intent' | 'outline' | 'draft' | 'review') ?? 'intent',
|
||||
failedModel: String(o.failed_model ?? (o as Record<string, unknown>).failedModel ?? ''),
|
||||
nextModel: String(o.next_model ?? (o as Record<string, unknown>).nextModel ?? ''),
|
||||
reason: String(o.reason ?? ''),
|
||||
sectionIndex: typeof o.section_index === 'number'
|
||||
? (o.section_index as number)
|
||||
: (typeof (o as Record<string, unknown>).sectionIndex === 'number'
|
||||
? ((o as Record<string, unknown>).sectionIndex as number)
|
||||
: undefined),
|
||||
sectionTitle: typeof o.section_title === 'string'
|
||||
? (o.section_title as string)
|
||||
: (typeof (o as Record<string, unknown>).sectionTitle === 'string'
|
||||
? ((o as Record<string, unknown>).sectionTitle as string)
|
||||
: undefined),
|
||||
};
|
||||
}
|
||||
return merged as unknown as ProgressEvent;
|
||||
}
|
||||
|
||||
|
||||
@@ -172,6 +172,7 @@ export type ProgressEventType =
|
||||
| "await_user"
|
||||
| "resumed"
|
||||
| "notice"
|
||||
| "model_switch"
|
||||
| "skill_search_step"
|
||||
| "skill_select_chunk"
|
||||
| "done"
|
||||
@@ -215,6 +216,16 @@ export interface ProgressEvent {
|
||||
blockedSections?: BlockedSection[];
|
||||
// skill_search_step:技能检索步骤事件(list_skills / select_skills / invoke_skill)
|
||||
skillStep?: SkillSearchStep;
|
||||
// model_switch:模型自动容错切换(某模型挂掉,换下一个模型继续)
|
||||
// stage ∈ intent / outline / draft / review,前端 reducer 据此清掉对应阶段的流式缓冲。
|
||||
modelSwitch?: {
|
||||
stage: "intent" | "outline" | "draft" | "review";
|
||||
failedModel: string;
|
||||
nextModel: string;
|
||||
reason: string;
|
||||
sectionIndex?: number;
|
||||
sectionTitle?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// ── 用户干预提交体 ────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in new issue
Block a user