From 38a74e39110287ce2a27a0d2c87ef7bfce164a6d Mon Sep 17 00:00:00 2001 From: Blizzard Date: Wed, 24 Jun 2026 15:53:30 +0800 Subject: [PATCH] =?UTF-8?q?fix(history):=20=E7=A9=BA=E7=AD=94=E5=A4=8D?= =?UTF-8?q?=E4=B8=8D=E8=90=BD=E5=8E=86=E5=8F=B2=20+=20=E5=8F=91=E9=80=81?= =?UTF-8?q?=E5=89=8D=E8=BF=87=E6=BB=A4=E7=A9=BA=E6=B6=88=E6=81=AF=EF=BC=88?= =?UTF-8?q?=E6=A0=B9=E6=B2=BB=E4=BC=9A=E8=AF=9D=E6=AF=92=E5=8C=96=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 现象:某轮 LLM 返回空(余额不足/失败/降级)→ 空 assistant 消息被写进会话历史 → 此后该 session 每次请求都带一条空 content 的 assistant 消息 → DeepSeek 400 "Invalid assistant message: content or tool_calls must be set" → 又空 → 又写空 → 自我循环毒化。 - Fix A(断源):orchestrator.memorize 对空答复直接 return,不落历史(失败的一轮不留痕)。 - Fix B(兜底):buildMessages 发送前剔除 content 与 tool_calls 均空的历史消息, 防御任何来源的脏历史(含存量)。 验证:清理被污染的 default 会话后恢复正常;新逻辑下空答复不再写入。dispatcher build+vet+test 全绿。 注:诊断中发现激活模型 deepseek-v4-pro 是推理模型(答案在 reasoning_content,content 为空), 已在管理端切回 deepseek-chat。仍待办:LLM 调用失败应暴露为 failed 而非 done-空输出。 Co-Authored-By: Claude Opus 4.8 (1M context) --- sundynix-dispatcher/internal/eino/compile.go | 11 ++++++++++- sundynix-dispatcher/internal/eino/orchestrator.go | 6 ++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/sundynix-dispatcher/internal/eino/compile.go b/sundynix-dispatcher/internal/eino/compile.go index 82527d2..3659104 100644 --- a/sundynix-dispatcher/internal/eino/compile.go +++ b/sundynix-dispatcher/internal/eino/compile.go @@ -48,9 +48,18 @@ func buildMessages(ctx context.Context, rc *RunCtx) ([]*schema.Message, error) { sys.WriteString("\n\n以下是前序协作 agent 的产出,请在此基础上继续完成你的部分(不要重头再来):\n") sys.WriteString(strings.Join(rc.Upstream, "\n---\n")) } + // 防御:剔除空 content 的历史消息。OpenAI 兼容 API 拒绝「content 与 tool_calls 都为空」的 + // assistant 消息(400 Invalid assistant message),一条脏历史会毒化整段会话的后续请求。 + history := make([]*schema.Message, 0, len(rc.History)) + for _, m := range rc.History { + if m == nil || (strings.TrimSpace(m.Content) == "" && len(m.ToolCalls) == 0) { + continue + } + history = append(history, m) + } return chatTemplate.Format(ctx, map[string]any{ "system": sys.String(), - "history": rc.History, + "history": history, "query": rc.Query, }) } diff --git a/sundynix-dispatcher/internal/eino/orchestrator.go b/sundynix-dispatcher/internal/eino/orchestrator.go index 4019d4b..0727535 100644 --- a/sundynix-dispatcher/internal/eino/orchestrator.go +++ b/sundynix-dispatcher/internal/eino/orchestrator.go @@ -8,6 +8,7 @@ import ( "fmt" "log" "log/slog" + "strings" "sync" "time" @@ -270,6 +271,11 @@ const consolidateEveryTurns = 3 // memorize 写回阶段(异步、离热路径):落短期历史;每 N 轮做一次记忆对账(consolidate)。 func (o *Orchestrator) memorize(t *contract.Task, answer string) { + // 空答复(LLM 失败/降级/拒绝)不落历史:空 assistant 消息会被 LLM API 拒绝(400), + // 一旦写入会毒化该会话后续所有请求。失败的一轮干脆不留痕。 + if strings.TrimSpace(answer) == "" { + return + } uid, _ := t.Meta[contract.MetaUserID].(string) sid, _ := t.Meta[contract.MetaSessionID].(string) if sid != "" && o.tools != nil {