Skip to content

tangdou Backend Token 裁剪方案 ​

适用版本:2026-09-08 之后
维护者:tangdou 后端组
读者:后续开发者、AI 优化助手、技术评审

本文档描述 tangdou Backend 在 chat_service 流式链路中如何控制 LLM 上下文大小,以及该方案如何从"轮次硬清空 + 静默 bug"演进到"双轨裁剪 + 数据矛盾兜底"。


0. 快速上手 ​

  1. 目标:单会话总 token 不超过服务端 n_ctx,同时尽量保留最近多轮上下文供追问。
  2. 核心算法:在 producer 线程里,每次 LLM 调用前整轮删除最早历史 + 原位截断巨型 ToolMessage,用 LangGraph RemoveMessage(id=...) 精确修改 checkpoint。
  3. 预算公式:
    text
    history_budget = min(
        SESSION_MAX_TOKENS,                       # 用户配置,默认 3000
        LLM_CONTEXT_SIZE                          # 服务端 n_ctx,默认 8192
          − estimate_tokens(system_prompt)        # AGENTS.md + rules.md,实测 ≈ 3334
          − _TOOL_SCHEMA_TOKENS(1500)             # 4 个工具的 schema
          − _REPLY_RESERVE(512),                  # 本轮回复 + tool_call 余量
    )
    history_budget = max(history_budget, 300)     # 下限兜底
  4. 调整入口:.env 文件 SESSION_MAX_TOKENS / LLM_CONTEXT_SIZE。
  5. 不要做的事:不要单边降 n_ctx 来"提速",详见 §7 教训。

1. 问题背景 ​

1.1 为什么需要 token 裁剪 ​

  • LLM 服务端(llama-server 启动的 localhost:8080)有 n_ctx 硬上限(典型 8192)。
  • 超过上限会返回 400: request exceeds available context size。
  • 用户多轮追问会让 messages 列表线性增长,几乎必然爆。

1.2 初始方案的失败(PR 之前) ​

1.2.1 轮次硬清空 ​

[chat_service.py:178](file:///d:/1sqyai/tangdou/backend/app/services/chat_service.py#L178) _reset_long_thread_if_needed:

python
if msg_count > 10:
    agent.update_state(config, {"messages": [RemoveMessage(id=m.id) for m in state.values["messages"]]})

问题:10 条消息直接全部清空(仅留 system),多轮追问("刚才那个结果再按年份分")立即失效,用户体验断崖。

1.2.2 Token 估算不准 + 预算为负静默跳过 ​

python
budget = session_max_tokens - estimate_tokens(prompt) - 512
if budget <= 0:
    logger.warning(...); return False  # ← 关键 bug:直接跳过

问题:实测 system_prompt ≈ 4000 tokens(AGENTS.md + schema_rules.md),加工具 schema ≈ 2000,budget 必然为负 → 历史完全没裁全量发出。

1.2.3 错误识别漏词 ​

python
if "context_length" in msg or "2013" in msg or "maximum" in msg.lower():
    ctx.answer = "本轮会话已过长..."

问题:漏掉 "exceed_context_size_error" 关键词,8431>8192 的 400 错误直接展示成"参数错误",让开发者以为是参数 bug。

1.3 三者叠加效应 ​

  • 硬清空让多轮失效
  • 估算不裁让会话必爆
  • 错误识别漏词让失败变隐形

→ 会话稍长必爆,爆了用户看不出来,只能猜是"模型有问题"。


2. 专家建议 ​

单会话总 token 硬上限 2048。业务组装 messages 列表时做滚动裁剪:保留 system,删除最早的 user-assistant 对话对,把总 token 压在 2048 以内。不要无脑把全部历史全部塞给 API。

要点提炼:

  • 滚动:只删最早,不清空
  • 整轮:user-assistant 对一起删,保 tool_call/result 配对
  • 保留 system:永不删
  • 贴近业务:在真实 messages 列表上算,不是改 prompt

3. 实现概览 ​

3.1 代码骨架 ​

backend/app/
├── services/
│   └── chat_service.py            ← 调度:producer 线程、LLM 流式、裁剪入口
├── utils/
│   └── token_counter.py           ← 纯函数:估算 / 分轮 / 裁剪 / ToolMessage 截断
└── core/
    └── config.py                  ← SESSION_MAX_TOKENS / LLM_CONTEXT_SIZE

3.2 关键调用时序 ​

ChatService._run(message)
  └─ producer 线程 ── agent.stream(input)
                        ↓
                  每 chunk:
                        ↓
                  _dispatch_tool_calls(tool_results)
                        ↓
                  ctx 写入 SQL 结果到 ctx.pending_sql_*
                        ↓
                  stream 收尾前
                        ↓
                  _trim_history_by_tokens(stream_config, ctx)
                        ├─ agent.get_state(config)         ← 单次读 state
                        ├─ plan_trim(...)                   ← 算要删除的 message id
                        ├─ plan_tool_message_truncation(...) ← 算巨型 ToolMessage 截断
                        └─ agent.update_state({messages: patches})
                              ↓
                            [RemoveMessage(id) | 原位 ToolMessage 替换]

3.3 关键文件索引 ​

关注点文件 : 行
裁剪入口[chat_service.py#L237-L262](file:///d:/1sqyai/tangdou/backend/app/services/chat_service.py#L237-L262)
预算公式[chat_service.py#L192-L229](file:///d:/1sqyai/tangdou/backend/app/services/chat_service.py#L192-L229)
错误识别[chat_service.py#L170-L176](file:///d:/1sqyai/tangdou/backend/app/services/chat_service.py#L170-L176)
估算/分轮/裁剪 纯函数[token_counter.py](file:///d:/1sqyai/tangdou/backend/app/utils/token_counter.py#L1-L200)
ToolMessage 截断[token_counter.py#L93-L136](file:///d:/1sqyai/tangdou/backend/app/utils/token_counter.py#L93-L136)
配置常量[config.py#L100-L105](file:///d:/1sqyai/tangdou/backend/app/core/config.py#L100-L105)
提示词瘦身策略[ontology/AGENTS.md](file:///d:/1sqyai/tangdou/backend/AGENTS.md)
数据矛盾兜底[tool_handlers.py#L269-L282](file:///d:/1sqyai/tangdou/backend/app/services/tool_handlers.py#L269-L282)

4. 详细实现 ​

4.1 token 估算([token_counter.py#L20-L50](file:///d:/1sqyai/tangdou/backend/app/utils/token_counter.py#L20-L50)) ​

python
def estimate_tokens(text: str) -> int:
    """保守估算。ASCII 按 3.5 字符/token,中文按 1 字符 ≈ 1.5 token。"""
    if not text:
        return 0
    n = len(text)
    ascii_cnt = sum(1 for c in text if ord(c) < 128)
    cjk_cnt = n - ascii_cnt
    return int(ascii_cnt / 3.5 + cjk_cnt * 1.5)

设计取舍:

  • 不依赖 tiktoken 等 tokenizer 库(节省 100MB+ 依赖、零延迟)
  • 宁多估不漏算:3.5 字符/token 偏保守,不会"省过头"导致 400
  • 对短英文偏差较大(真实 ~4 字符/token),但对问数系统的中文 query 影响小

4.2 按轮次切分([token_counter.py#L52-L80](file:///d:/1sqyai/tangdou/backend/app/utils/token_counter.py#L52-L80)) ​

python
def split_rounds(messages: list) -> list:
    """把 messages 按 HumanMessage 起止切成轮次,SystemMessage 永不参与。"""
    rounds = []
    cur = {"system": None, "turns": []}
    for m in messages:
        cls = m.__class__.__name__
        if cls == "SystemMessage":
            cur["system"] = m
        elif cls == "HumanMessage":
            if cur["turns"] or cur["system"]:
                rounds.append(cur)
            cur = {"system": None, "turns": [m]}
        else:
            cur["turns"].append(m)
    if cur["turns"] or cur["system"]:
        rounds.append(cur)
    return rounds

关键点:整轮(user → assistant → tool_call → tool_result)一起删,保证 tool_call/result 配对完整,否则 LLM 看到 tool_call 没 tool_result 会进入"等待回复"状态无法继续。

4.3 滚动裁剪([token_counter.py#L82-L120](file:///d:/1sqyai/tangdou/backend/app/utils/token_counter.py#L82-L120)) ​

python
def plan_trim(messages: list, budget: int) -> list:
    """返回要删除的 message id 列表。从最早轮次开始删,直到 token 总和 ≤ budget。"""
    # 1. SystemMessage 永远保留
    # 2. 最近 1 轮永不删(否则当前 query 上下文丢了)
    # 3. 从最早的轮次开始整轮删除
    ...

调用示例:

python
remove_ids = plan_trim(messages, budget)
trunc_plans = plan_tool_message_truncation(messages, budget)
patches = [RemoveMessage(id=i) for i in remove_ids]
for mid, tc_id, new_content in trunc_plans:
    patches.append(ToolMessage(id=mid, content=new_content, tool_call_id=tc_id))
agent.update_state(stream_config, {"messages": patches})

4.4 巨型 ToolMessage 截断([token_counter.py#L93-L136](file:///d:/1sqyai/tangdou/backend/app/utils/token_counter.py#L93-L136)) ​

最近一轮里如果某个 ToolMessage.content 超 600 tokens(例如 sql_db_schema 返回的全库结构),按 id 原位截断到 1/4:

python
plans.append((msg.id, msg.tool_call_id, original[: new_token_budget_4 // 4]))

利用 LangGraph add_messages reducer 按 id upsert,toolid 不重排、消息不重复。

4.5 预算公式([chat_service.py#L192-L229](file:///d:/1sqyai/tangdou/backend/app/services/chat_service.py#L192-L229)) ​

python
# 历史预算 = min(用户配置, n_ctx - 固定开销)
history_budget = min(
    config.session_max_tokens,             # .env SESSION_MAX_TOKENS, 默认 3000
    config.llm_context_size                # .env LLM_CONTEXT_SIZE,  默认 8192
    - _estimate_system_tokens()            # 实测 ≈ 3334
    - _TOOL_SCHEMA_TOKENS                  # 常量 1500
    - _REPLY_RESERVE_TOKENS,               # 常量 512
)
history_budget = max(history_budget, _MIN_HISTORY_TOKENS)  # 下限 300

为什么不是简单的 SESSION_MAX_TOKENS:

  • 用户改 n_ctx 到 16384 时,min 自动放开历史预算
  • system 增大时(增加硬约束章节),min 自动收紧历史预算
  • 永远尊重服务端硬上限

4.6 错误识别([chat_service.py#L170-L176](file:///d:/1sqyai/tangdou/backend/app/services/chat_service.py#L170-L176)) ​

python
msg_lower = msg.lower()
if "context_length" in msg_lower or "2013" in msg \
   or "maximum" in msg_lower or "exceed" in msg_lower:
    ctx.answer = "本轮会话已过长,触发上下文长度上限..."

exceed_context_size_error 关键词已加入。

4.7 配置外部化 ​

[.env](file:///d:/1sqyai/tangdou/backend/.env):

bash
SESSION_MAX_TOKENS=3000   # 历史消息预算
LLM_CONTEXT_SIZE=8192     # 服务端 n_ctx

切换服务商 / 升级上下文只需改 .env 一行。


5. 配套优化(与裁剪正交) ​

5.1 瘦 system prompt ​

文件状态体积
ontology/schema_rules.md.dmreport→ .disabled(不再下发)6705B → 0
AGENTS.md保留(分类规则 + 安全规则 + 真实性硬约束)3729B + 1050B
ontology/rules.md保留(本体规则不可替代)—
system prompt 实测3334 tokens

为什么这样瘦:完整字段定义属于"查询时按需读"信息,LLM 会自动调 sql_db_schema 拿实时库结构,静态文档既占空间又易过期。

5.2 减工具集 ​

SQLDatabaseToolkit 默认 4 个工具,移除 2 个([agent.py#L217-L221](file:///d:/1sqyai/tangdou/backend/app/agent/agent.py#L217-L221)):

  • sql_db_query_checker(内含额外 LLM 调用,每查询多消耗 5~15s)
  • sql_db_schema(输出全库结构可达数千 token,触发 n_ctx 紧吃)

保留:sql_db_query + sql_db_list_tables + to_echarts_toolkit。

AGENTS.md 中三处 sql_db_schema 引用同步移除。

5.3 数据矛盾兜底([tool_handlers.py#L269-L282](file:///d:/1sqyai/tangdou/backend/app/services/tool_handlers.py#L269-L282)) ​

LLM 即使看到真数据,仍可能在 tool_call args 里把 5 行扩写成 800+ 行:

python
if ctx.pending_sql_rows:
    ctx_n = len(ctx.pending_sql_rows)
    llm_n = len(rows)
    if llm_n > max(ctx_n * 3, 50):
        logger.warning(
            "[handle_echarts] LLM-args.rows(%d) 与 ctx.pending_sql_rows(%d) 行数差距过大,"
            "丢弃 LLM 数据,改用数据库实数据",
            llm_n, ctx_n,
        )
        rows = ctx.pending_sql_rows
        columns = ctx.pending_sql_columns

阈值 max(ctx_n * 3, 50):容忍小幅语义修正(如 5 行→15 行是合理的"展开"),捕获大幅幻觉。

5.4 warning 折叠([chart_tool.py#L244-L313](file:///d:/1sqyai/tangdou/backend/app/agent/tools/chart_tool.py#L244-L313)) ​

LLM 编 866 行 0 元素时,每行一条 warning 会污染日志 870+ 行。改为:

python
_WARN_ROW_FIRST = 3     # 前 3 条打 warning
_WARN_ROW_LAST = 2       # 末 2 条打 warning
_WARN_ROW_THRESHOLD = 5  # 超此值折叠成 summary

超阈值后输出 前 3 + 末 2 + "(suppressed 866 more)" 共 6 行。


6. 验证 ​

6.1 单测覆盖 ​

token_counter.py 全部为纯函数,可独立测试:

bash
cd backend
.\venv\Scripts\python.exe -m pytest tests/test_token_counter.py -v

(或直接 python -c "from app.utils.token_counter import ...")

6.2 真实场景验证(用户记录) ​

system_tokens  = 3334(实测 AGENTS.md + rules.md)
budget         = min(3000, 8192 − 3334 − 1500 − 512) = 2846

4551 tokens 历史 →
  step1 整轮删 20 条(最早 5 轮)  → 1700 tokens
  step2 截断巨型 t5               →  400 tokens
最终:400 tokens ≤ 2846 ✓

6.3 端到端验证清单 ​

部署/重构后必须实测:

  1. 不爆:连续追问 6~8 轮后查询 → 日志无 exceed_context_size_error
  2. 多轮可用:追问"刚才那个结果再按年份分" → LLM 能从历史读出上次 SQL
  3. 数据正确:数据库 5 行 → 表格显示 5 行(不是 LLM 编造的 800 行)
  4. 日志干净:连续 10 次查询 → 日志中没有 hundreds 条 Row X has 0 elements warning

7. 演进教训 ​

7.1 我犯过的错:单边降 n_ctx ​

背景:想让 LLM prefill 更快,把 LLM_CONTEXT_SIZE 从 8192 调到 4096。

结果:

system(3334) + tools(1500) + reserve(512) = 5346 > 4096
→ history_budget 被压到下限 300
→ LLM 拿不到真实 SQL 结果
→ 在 tool_call args 里编造 866 行数据
→ 总耗时反而 142s(本来 60~130s),结果完全错

修正:把 n_ctx 调回 8192,把省 token 的着力点放到 system/tools 自身,不要试图压缩窗口来"省"时间。

7.2 优化顺序(正确的思考链) ​

  1. ✅ 瘦 system(最有效,直接省 system_prompt_token)
  2. ✅ 减工具集(省 schema_token + 省一次 LLM 调用)
  3. ✅ 调准 schema 估算常量(避免 budget 算错)
  4. ✅ 实测算 token 公式(n_ctx − system − tools − reserve)
  5. ⚠️ 降 n_ctx 是最后手段,且只在历史用不完时使用
  6. ⚠️ 彻底根治 LLM 幻觉:去掉 to_echarts_toolkit 工具,让 dispatch 全权接管(未做)

7.3 反直觉:调小 n_ctx 不提速 ​

qwen3-4b 在 CPU 上:prefill 几十 ms,decode 才是瓶颈。把 n_ctx 8192→4096 不会让 142s 变成 60s,只会因为 LLM 幻觉导致数据错误。


8. 进一步优化(未做) ​

8.1 真 tokenizer 切换 ​

python
import tiktoken
enc = tiktoken.encoding_for_model("gpt-3.5-turbo")
def estimate_tokens(text: str) -> int:
    return len(enc.encode(text))
  • 精度 ±5%
  • 代价:+100MB 依赖、首调用延迟 ~50ms(编码器加载)
  • 当前字符估算对问数系统足够,不建议加

8.2 动态 n_ctx 自适应 ​

python
if "exceed_context_size_error" in error_msg:
    n_prompt_tokens = parse_n_prompt_tokens(error_msg)
    config.llm_context_size = n_prompt_tokens + 512  # 自适应
    return "本轮会话已过长,已自动扩容..."

避免重复踩同样的坑。

8.3 SQL 工具按需注入 ​

把 sql_db_query 和 to_echarts_toolkit 分两阶段注入:先 query,用户表达"做图"意图后再注入 chart。省 ~500 tokens 给历史。改动较大,不建议在 v1 实施。

8.4 彻底去除 to_echarts_toolkit ​

让 dispatch 在 sql_db_query 完成后直接生成图表(基于 rows 推断 chart_type),LLM 只负责出自然语言总结。

预期收益:消除 LLM 在 tool_call 里编造数据的耗时(约占总时间 60~80%)。
代价:dispatch 端的图表类型推断要覆盖更多场景,需要完整的 unit test 矩阵。
建议时机:当 qwen3-4b 频繁出现 rows 截断/嵌套 dict 问题时。


9. 调整指南(运维 / 二次开发) ​

9.1 何时调整 SESSION_MAX_TOKENS ​

现象调整
日志频繁 History trimmed: removed 12 messages调小(历史已用不完)
多轮追问上下文丢失("刚才那个"答不上)调大,但要先瘦 system
400: exceed_context_size_error 频繁出现先瘦 system,再调 LLM_CONTEXT_SIZE

9.2 何时调整 LLM_CONTEXT_SIZE ​

现象调整
客户端显存/内存吃紧(llama-server)调小
system + tools 永远逼近 n_ctx调大(如 16384)
频繁 exceed_context_size_error调大(看 LLM 服务端 --ctx-size 是否也调)

注意:必须同步调整 llama-server 启动参数 --ctx-size,否则只是"放宽后端限制、前端兜底"。

9.3 何时调整系统 prompt ​

每次新增"硬约束"章节时:

bash
python -c "from app.utils.token_counter import estimate_tokens; print(estimate_tokens(open('AGENTS.md').read()))"

确认仍在预算内(推荐 < 2500 tokens;当前 3334 已接近上限)。


10. 附录 ​

10.1 关键常量速查 ​

常量默认位置含义
SESSION_MAX_TOKENS3000[config.py#L100](file:///d:/1sqyai/tangdou/backend/app/core/config.py#L100)用户配置的会话预算
LLM_CONTEXT_SIZE8192[config.py#L102](file:///d:/1sqyai/tangdou/backend/app/core/config.py#L102)服务端 n_ctx
_TOOL_SCHEMA_TOKENS1500[chat_service.py#L197](file:///d:/1sqyai/tangdou/backend/app/services/chat_service.py#L197)4 工具 schema 估算
_REPLY_RESERVE_TOKENS512[chat_service.py#L196](file:///d:/1sqyai/tangdou/backend/app/services/chat_service.py#L196)本轮回复 + tool_call 余量
_MIN_HISTORY_TOKENS300[chat_service.py#L200](file:///d:/1sqyai/tangdou/backend/app/services/chat_service.py#L200)历史预算下限

10.2 演进时间线 ​

日期事件
2026-08初始:硬清空 + 静默跳过 bug
2026-09-01引入 token_counter.py,实现整轮裁剪 + ToolMessage 截断
2026-09-03预算公式改为 n_ctx 约束;下限 300 兜底
2026-09-05exceed_context_size_error 加入错误识别
2026-09-06移除 sql_db_query_checker、sql_db_schema;AGENTS.md 移除对应引用
2026-09-07错误尝试降 n_ctx 到 4096,导致 LLM 幻觉 → 撤回
2026-09-07_TOOL_SCHEMA_TOKENS 调到 1500(实测)
2026-09-08数据矛盾 fallback + warning 折叠

10.3 关键教训 ​

  1. 先让固定成本变小(system + tools),再让历史预算自然变大
  2. 不要单边降 n_ctx 来"提速",代价是 LLM 幻觉
  3. 永远尊重服务端 n_ctx 硬上限,budget = min(用户配置, n_ctx - 固定)

Released under the MIT License.