fix(dispatcher): 报告 LLM 调用加单次超时上限,治偶发卡死

之前 writeSection/planOutline/planItems 的 pool.Chat 用无 deadline 的 ctx,个别
DeepSeek 流连接挂住会一直占着(曾累积把整篇卡死)。给每次 LLM 调用套 60s 超时
(llmCtx):超时即 cancel → 底层 http 请求中断 → Chat 返回错误 → 走兜底,不无限等。
happy path(约18s 完成)不变,仅加上限。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-06-18 11:47:46 +08:00
parent 5ec558bf81
commit 718140239d
2 changed files with 17 additions and 4 deletions
+1 -1
View File
@@ -99,7 +99,7 @@
- [ ] 6 个提交待 push`5d76652``79f9912`,需在普通终端 `git push origin main` - [ ] 6 个提交待 push`5d76652``79f9912`,需在普通终端 `git push origin main`
- [ ] PDF 导出 Wails 真机验证(不行则回退后端内嵌 CJK 字体出 PDF) - [ ] PDF 导出 Wails 真机验证(不行则回退后端内嵌 CJK 字体出 PDF)
- [ ] 报告生成并发健壮性(writeSections 降并发 / 加单次超时,治偶发卡顿 - [x] 报告生成并发健壮性(每次 LLM 调用 60s 超时上限,挂死自释放;规划/分章/撰写均套
- [ ] MinIO 大文档改名/删除的孤儿对象 GC - [ ] MinIO 大文档改名/删除的孤儿对象 GC
- [x] `make test` 目标(test-go / test-web / test-py 一键跑) - [x] `make test` 目标(test-go / test-web / test-py 一键跑)
+16 -3
View File
@@ -18,8 +18,15 @@ import (
const ( const (
reportFanout = 4 // 章节并行撰写的最大并发 reportFanout = 4 // 章节并行撰写的最大并发
reportRenderWait = 12 * time.Second // 渲染 docx 的等待上限 reportRenderWait = 12 * time.Second // 渲染 docx 的等待上限
reportLLMTimeout = 60 * time.Second // 单次 LLM 调用(规划/撰写一章)的超时上限
) )
// llmCtx 给单次报告 LLM 调用套超时,避免个别请求挂死拖垮整篇(曾遇连接累积卡死)。
// 超时即 cancel → 底层 http 请求中断 → Chat 返回错误 → 调用方走兜底,不无限等待。
func llmCtx(ctx context.Context) (context.Context, context.CancelFunc) {
return context.WithTimeout(ctx, reportLLMTimeout)
}
// reportOutline 是规划阶段产出的大纲。 // reportOutline 是规划阶段产出的大纲。
type reportOutline struct { type reportOutline struct {
Title string `json:"title"` Title string `json:"title"`
@@ -105,7 +112,9 @@ func (o *Orchestrator) planOutline(ctx context.Context, topic string) reportOutl
sys := "你是资深报告撰稿人,擅长搭建清晰的报告结构。" sys := "你是资深报告撰稿人,擅长搭建清晰的报告结构。"
user := fmt.Sprintf("请为主题《%s》规划一份报告大纲。"+ user := fmt.Sprintf("请为主题《%s》规划一份报告大纲。"+
"只输出 JSON{\"title\":\"报告标题\",\"sections\":[\"章节标题\", ...]}3 到 5 章,不要任何多余文字。", topic) "只输出 JSON{\"title\":\"报告标题\",\"sections\":[\"章节标题\", ...]}3 到 5 章,不要任何多余文字。", topic)
txt, err := o.pool.Chat(ctx, []llm.ChatMessage{{Role: "system", Content: sys}, {Role: "user", Content: user}}) cctx, cancel := llmCtx(ctx)
defer cancel()
txt, err := o.pool.Chat(cctx, []llm.ChatMessage{{Role: "system", Content: sys}, {Role: "user", Content: user}})
if err != nil { if err != nil {
log.Printf("[report] 规划大纲失败,用兜底大纲: %v", err) log.Printf("[report] 规划大纲失败,用兜底大纲: %v", err)
return fallback return fallback
@@ -134,7 +143,9 @@ func (o *Orchestrator) planItems(ctx context.Context, topic, splitBy string) []s
} }
user := fmt.Sprintf("请把主题《%s》拆分为一组「%s」,用于并行撰写。"+ user := fmt.Sprintf("请把主题《%s》拆分为一组「%s」,用于并行撰写。"+
"只输出 JSON 数组:[\"子项1\",\"子项2\", ...]3 到 6 项,不要任何多余文字。", topic, hint) "只输出 JSON 数组:[\"子项1\",\"子项2\", ...]3 到 6 项,不要任何多余文字。", topic, hint)
txt, err := o.pool.Chat(ctx, []llm.ChatMessage{ cctx, cancel := llmCtx(ctx)
defer cancel()
txt, err := o.pool.Chat(cctx, []llm.ChatMessage{
{Role: "system", Content: "你擅长把一个任务拆解为可并行处理的若干子项。"}, {Role: "system", Content: "你擅长把一个任务拆解为可并行处理的若干子项。"},
{Role: "user", Content: user}, {Role: "user", Content: user},
}) })
@@ -193,7 +204,9 @@ func (o *Orchestrator) writeSection(ctx context.Context, topic, kb, heading stri
ub.WriteString("\n") ub.WriteString("\n")
} }
ub.WriteString("请就「本章标题」撰写 200–400 字正文。只输出正文,不要重复标题、不要再列提纲。") ub.WriteString("请就「本章标题」撰写 200–400 字正文。只输出正文,不要重复标题、不要再列提纲。")
txt, err := o.pool.Chat(ctx, []llm.ChatMessage{{Role: "system", Content: sys}, {Role: "user", Content: ub.String()}}) cctx, cancel := llmCtx(ctx)
defer cancel()
txt, err := o.pool.Chat(cctx, []llm.ChatMessage{{Role: "system", Content: sys}, {Role: "user", Content: ub.String()}})
if err != nil { if err != nil {
log.Printf("[report] 撰写「%s」失败: %v", heading, err) log.Printf("[report] 撰写「%s」失败: %v", heading, err)
return "(本章撰写失败:" + err.Error() + "" return "(本章撰写失败:" + err.Error() + ""