Skip to content

糖豆(tangdou)历史过长触发 Token 上限问题 ​

1. 问题现象 ​

用户在与糖豆多轮对话后,偶尔会看到如下错误(用户侧仅显示"查询完成,但未获取到具体结果",完整错误只在后端日志里):

text
openai.BadRequestError: Error code: 400 -
{'type': 'error',
 'error': {'type': 'bad_request_error',
           'message': 'invalid params, 400 (2013)',
           'http_code': '400'},
 ...}

错误码 2013 是 MiniMax API 的"参数无效"分类,在这个场景下根因是:累计的对话历史超过了 LLM 的上下文窗口上限。

2. 根因分析 ​

2.1 LangGraph 的 checkpoint 行为 ​

糖豆的 Agent 基于 LangGraph create_react_agent,每个会话(thread_id = "{user_id}_{conversation_id}")的完整消息历史会持久化在 SQLite checkpoints / writes 表中。每次调用 agent.stream(...) 时:

  • 框架从 checkpointer 读取该 thread 的全部历史 messages
  • 把这次新输入的 HumanMessage 通过 add_messages reducer 追加进去
  • 合并后的全量 messages 一次性发给 LLM

历史越长,token 用量越大。当累计 tokens 超过模型上限(MiniMax 不同模型不同,常见 8k/16k/32k)时,API 端直接返回 400。

2.2 为什么之前没出现 ​

项目早期使用 Ollama 本地模型(qwen2.5:7b 等),本地推理对上下文长度限制较宽松;切换到 MiniMax 云端 API 后,约束收紧,多轮对话累积到一定轮次就会触发。

2.3 错误为什么没展示给用户 ​

_run 中虽然有通用 except Exception 把异常转成 SSE error 事件,但消息体是技术细节(BadRequestError: Error code: 400 - ...),用户看不懂;更糟的是异常后 ctx.answer 为空,_finalize 会兜底成"查询完成,但未获取到具体结果",掩盖了真实问题。

3. 解决方案 ​

改动集中在 [chat_service.py](file:///d:/1sqyai/tangdou/backend/app/services/chat_service.py) 一个文件,分两层防护:

3.1 第一层:主动重置过长 thread ​

_reset_long_thread_if_needed(agent, stream_config):每次 stream 前检测 thread 中 human 消息数量,超过 _MAX_HISTORY_TURNS = 10 就直接调用 checkpointer.delete_thread(thread_id) 清空 LangGraph 推理用的历史。

python
user_count = sum(
  1 for m in messages if getattr(m, "type", None) == "human"
)
if user_count > ChatService._MAX_HISTORY_TURNS:
  agent.checkpointer.delete_thread(thread_id)
  • 用户在数据库 messages 表中持久化的对话历史不受影响,前端进入会话时仍可完整展示
  • 仅丢弃 LangGraph 推理所需的"工作记忆",让 agent 从干净状态开始新一轮对话
  • 配合 system prompt 中的 ontology,业务查询效果不受明显影响

为什么不在 stream input 上裁剪?LangGraph 的 add_messages reducer 会把新输入与 checkpoint 中现有 history 合并,stream 阶段无可靠覆写接口。最干净的做法是直接删除 thread 的 checkpoint。

3.2 第二层:友好错误兜底 ​

即使第一层覆盖不全(极端情况下仍可能触发),单独捕获 openai.BadRequestError,根据错误内容返回中文友好提示:

python
except OpenAIBadRequest as e:
    msg = str(e)
    if "context_length" in msg or "2013" in msg or "maximum" in msg.lower():
        ctx.answer = (
            "本轮会话已过长,触发上下文长度上限。"
            "已自动重置会话记忆,请重新发起问题。"
        )
    else:
        ctx.answer = f"LLM 调用参数错误: {msg}"
    yield format_error(ctx.answer)

提示通过 ctx.answer 写入,最终进入 SSE result 事件,前端用户看到的是清晰可读的中文,而不是堆栈。

3.3 调用顺序 ​

在 _producer 中两个修复按顺序执行:

python
def _producer():
    try:
        # 1) 历史过长 → 清空 checkpoint
        self._reset_long_thread_if_needed(agent, stream_config)
        # 2) 修复孤儿 tool_call(上一次中断遗留)
        self._repair_orphaned_tool_calls(agent, stream_config)
        # 3) 正常 stream
        stream_iter = agent.stream(...)

先重置再修孤儿,避免在裁剪掉过期 tool_call 后再注入占位 ToolMessage。

4. 验证 ​

  • 语法:python -c "import py_compile; py_compile.compile('app/services/chat_service.py', doraise=True)" 通过
  • 导入:from app.services.chat_service import ChatService 成功
  • 运行:与该 thread 对话超过 10 轮后再次提问,后端日志会出现 Thread ... history too long (N turns), checkpoint reset,前端不再显示 "查询完成,但未获取到具体结果",而是正常收到 agent 回复(基于清空后的历史 + 当前问题)

5. 后续可优化方向 ​

方向说明
配置化阈值把 _MAX_HISTORY_TURNS 提到 Config,按模型上下文上限动态调整
摘要压缩删除 checkpoint 前先用 LLM 对历史做摘要、保留前 N 轮摘要 + 后 M 轮原文,兼顾记忆与上下文
滑动窗口保留最近 N 轮而非清空,更接近人类记忆衰减模型(实现复杂度更高,需要直接操作 LangGraph internal state)
模型路由长会话自动切换到上下文更大的模型(如 abab6.5s-chat 支持 32k)

Released under the MIT License.