fix(dispatcher): Map 节点错误传播 —— 并行子项失败不再静默(T4.E)
- writeSection 返回 (body, error):仅真·LLM 调用失败返 err;预算触顶/模型未配置 是主动降级(可见降级正文,err=nil)不计失败 - writeSections 返回 ([]section, failed):失败项 Body 带可见「撰写失败」标记 + 汇总失败数 - mapNode:全部子项失败 → 置 b.fatalErr(任务判 failed 而非静默 done-空); 部分失败 → trace span + 流式 ⚠️ 告警 - report handleReport:部分章节失败时流式提示,不再当全成功 - 3 单测:全失败/部分精确计数(=1 非 all-or-nothing)/mapNode 置 fatalErr Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ import (
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/sundynix/sundynix-dispatcher/internal/dsl"
|
||||
@@ -75,7 +76,12 @@ func (o *Orchestrator) handleReport(ctx context.Context, t *contract.Task, tr *e
|
||||
o.emit(t.ID, "\n> 正在并行撰写各章…\n\n")
|
||||
}
|
||||
|
||||
sections := o.writeSections(ctx, topic, kb, outline.Sections, tr)
|
||||
sections, failed := o.writeSections(ctx, topic, kb, outline.Sections, tr)
|
||||
if failed > 0 {
|
||||
note := fmt.Sprintf("⚠️ %d/%d 章撰写失败(下方已标注),其余照常成稿。", failed, len(sections))
|
||||
tr.info("plan", "system", "部分章节失败", note)
|
||||
o.emit(t.ID, "\n> "+note+"\n\n")
|
||||
}
|
||||
|
||||
// 把完整报告正文流式呈现给客户端。
|
||||
o.emit(t.ID, "\n---\n\n# "+firstNonEmpty(outline.Title, topic)+"\n\n")
|
||||
@@ -162,11 +168,13 @@ func (o *Orchestrator) planItems(ctx context.Context, topic, splitBy string) []s
|
||||
return items
|
||||
}
|
||||
|
||||
// writeSections 各章节并行撰写(有界并发),结果按原顺序返回。
|
||||
func (o *Orchestrator) writeSections(ctx context.Context, topic, kb string, headings []string, tr *execTracer) []reportSection {
|
||||
// writeSections 各章节并行撰写(有界并发),结果按原顺序返回;第二返回值为失败项数。
|
||||
// 失败项的 Body 会带明确的失败标记(可见,不静默),失败计数供上游汇总/判定整体成败。
|
||||
func (o *Orchestrator) writeSections(ctx context.Context, topic, kb string, headings []string, tr *execTracer) ([]reportSection, int) {
|
||||
out := make([]reportSection, len(headings))
|
||||
sem := make(chan struct{}, reportFanout)
|
||||
var wg sync.WaitGroup
|
||||
var failed atomic.Int64
|
||||
for i, h := range headings {
|
||||
wg.Add(1)
|
||||
go func(i int, h string) {
|
||||
@@ -175,26 +183,33 @@ func (o *Orchestrator) writeSections(ctx context.Context, topic, kb string, head
|
||||
defer func() { <-sem }()
|
||||
node := fmt.Sprintf("section:%d", i)
|
||||
end := tr.span(node, "section", fmt.Sprintf("第%d章 %s", i+1, h))
|
||||
body := o.writeSection(ctx, topic, kb, h, tr, node)
|
||||
end(fmt.Sprintf("成稿 %d 字", len([]rune(body))), nil)
|
||||
body, err := o.writeSection(ctx, topic, kb, h, tr, node)
|
||||
if err != nil {
|
||||
failed.Add(1)
|
||||
end("撰写失败:"+err.Error(), err) // trace 记为该 section 失败(非静默)
|
||||
} else {
|
||||
end(fmt.Sprintf("成稿 %d 字", len([]rune(body))), nil)
|
||||
}
|
||||
out[i] = reportSection{Heading: h, Body: body}
|
||||
}(i, h)
|
||||
}
|
||||
wg.Wait()
|
||||
return out
|
||||
return out, int(failed.Load())
|
||||
}
|
||||
|
||||
// writeSection 撰写一章:先 RAG 检索参考资料(若挂了知识库),再让模型成稿。
|
||||
func (o *Orchestrator) writeSection(ctx context.Context, topic, kb, heading string, tr *execTracer, node string) string {
|
||||
// 返回 (正文, 失败错误):仅「真·LLM 调用失败」返回非 nil err;预算触顶 / 模型未配置是
|
||||
// 主动降级(出可见的降级正文、err=nil),不计入失败。
|
||||
func (o *Orchestrator) writeSection(ctx context.Context, topic, kb, heading string, tr *execTracer, node string) (string, error) {
|
||||
refs := o.retrieve(ctx, kb, topic+" "+heading)
|
||||
if refs != "" {
|
||||
tr.info(node, "section", "检索参考资料", truncate(strings.ReplaceAll(refs, "\n", " "), 120))
|
||||
}
|
||||
if !o.pool.Ready() {
|
||||
if refs != "" {
|
||||
return "(模型未配置,以下为检索到的参考资料)\n" + refs
|
||||
return "(模型未配置,以下为检索到的参考资料)\n" + refs, nil
|
||||
}
|
||||
return "(模型未配置,无法撰写本章。)"
|
||||
return "(模型未配置,无法撰写本章。)", nil
|
||||
}
|
||||
sys := "你是专业报告撰稿人,语言严谨、条理清晰,使用中文书面语。"
|
||||
var ub strings.Builder
|
||||
@@ -210,7 +225,7 @@ func (o *Orchestrator) writeSection(ctx context.Context, topic, kb, heading stri
|
||||
bud.AddPrompt(sys + ub.String())
|
||||
if bud.Exceeded() {
|
||||
tr.info(node, "system", "token 预算", "已达预算上限,跳过本章")
|
||||
return "(已达 token 预算上限,本章自动跳过。)"
|
||||
return "(已达 token 预算上限,本章自动跳过。)", nil
|
||||
}
|
||||
}
|
||||
cctx, cancel := llmCtx(ctx)
|
||||
@@ -218,12 +233,12 @@ func (o *Orchestrator) writeSection(ctx context.Context, topic, kb, heading stri
|
||||
txt, err := o.pool.Chat(cctx, []llm.ChatMessage{{Role: "system", Content: sys}, {Role: "user", Content: ub.String()}})
|
||||
if err != nil {
|
||||
log.Printf("[report] 撰写「%s」失败: %v", heading, err)
|
||||
return "(本章撰写失败:" + err.Error() + ")"
|
||||
return "(本章撰写失败:" + err.Error() + ")", err
|
||||
}
|
||||
if bud := harness.BudgetFrom(ctx); bud != nil {
|
||||
bud.AddComplete(txt) // 成本护栏:计入输出
|
||||
}
|
||||
return strings.TrimSpace(txt)
|
||||
return strings.TrimSpace(txt), nil
|
||||
}
|
||||
|
||||
// retrieve 经 Eino Retriever 组件(包 mcp-go kb_search)检索知识库,整理为可读参考资料。
|
||||
|
||||
Reference in New Issue
Block a user