fix(history): 空答复不落历史 + 发送前过滤空消息(根治会话毒化)

现象:某轮 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) <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-06-24 15:53:30 +08:00
parent 53669437d5
commit 38a74e3911
2 changed files with 16 additions and 1 deletions
+10 -1
View File
@@ -48,9 +48,18 @@ func buildMessages(ctx context.Context, rc *RunCtx) ([]*schema.Message, error) {
sys.WriteString("\n\n以下是前序协作 agent 的产出,请在此基础上继续完成你的部分(不要重头再来):\n") sys.WriteString("\n\n以下是前序协作 agent 的产出,请在此基础上继续完成你的部分(不要重头再来):\n")
sys.WriteString(strings.Join(rc.Upstream, "\n---\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{ return chatTemplate.Format(ctx, map[string]any{
"system": sys.String(), "system": sys.String(),
"history": rc.History, "history": history,
"query": rc.Query, "query": rc.Query,
}) })
} }
@@ -8,6 +8,7 @@ import (
"fmt" "fmt"
"log" "log"
"log/slog" "log/slog"
"strings"
"sync" "sync"
"time" "time"
@@ -270,6 +271,11 @@ const consolidateEveryTurns = 3
// memorize 写回阶段(异步、离热路径):落短期历史;每 N 轮做一次记忆对账(consolidate)。 // memorize 写回阶段(异步、离热路径):落短期历史;每 N 轮做一次记忆对账(consolidate)。
func (o *Orchestrator) memorize(t *contract.Task, answer string) { 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) uid, _ := t.Meta[contract.MetaUserID].(string)
sid, _ := t.Meta[contract.MetaSessionID].(string) sid, _ := t.Meta[contract.MetaSessionID].(string)
if sid != "" && o.tools != nil { if sid != "" && o.tools != nil {