diff --git a/PROGRESS.md b/PROGRESS.md index b0a1d04..124655d 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -99,7 +99,7 @@ - [ ] 6 个提交待 push(`5d76652` → `79f9912`,需在普通终端 `git push origin main`) - [ ] PDF 导出 Wails 真机验证(不行则回退后端内嵌 CJK 字体出 PDF) -- [ ] 报告生成并发健壮性(writeSections 降并发 / 加单次超时,治偶发卡顿) +- [x] 报告生成并发健壮性(每次 LLM 调用 60s 超时上限,挂死自释放;规划/分章/撰写均套) - [ ] MinIO 大文档改名/删除的孤儿对象 GC - [x] `make test` 目标(test-go / test-web / test-py 一键跑) diff --git a/sundynix-dispatcher/internal/eino/report.go b/sundynix-dispatcher/internal/eino/report.go index d3fbc97..ce15a98 100644 --- a/sundynix-dispatcher/internal/eino/report.go +++ b/sundynix-dispatcher/internal/eino/report.go @@ -18,8 +18,15 @@ import ( const ( reportFanout = 4 // 章节并行撰写的最大并发 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 是规划阶段产出的大纲。 type reportOutline struct { Title string `json:"title"` @@ -105,7 +112,9 @@ func (o *Orchestrator) planOutline(ctx context.Context, topic string) reportOutl sys := "你是资深报告撰稿人,擅长搭建清晰的报告结构。" user := fmt.Sprintf("请为主题《%s》规划一份报告大纲。"+ "只输出 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 { log.Printf("[report] 规划大纲失败,用兜底大纲: %v", err) return fallback @@ -134,7 +143,9 @@ func (o *Orchestrator) planItems(ctx context.Context, topic, splitBy string) []s } user := fmt.Sprintf("请把主题《%s》拆分为一组「%s」,用于并行撰写。"+ "只输出 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: "user", Content: user}, }) @@ -193,7 +204,9 @@ func (o *Orchestrator) writeSection(ctx context.Context, topic, kb, heading stri ub.WriteString("\n") } 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 { log.Printf("[report] 撰写「%s」失败: %v", heading, err) return "(本章撰写失败:" + err.Error() + ")"