tangdou Backend Token 裁剪方案
适用版本:2026-09-08 之后
维护者:tangdou 后端组
读者:后续开发者、AI 优化助手、技术评审
本文档描述 tangdou Backend 在 chat_service 流式链路中如何控制 LLM 上下文大小,以及该方案如何从"轮次硬清空 + 静默 bug"演进到"双轨裁剪 + 数据矛盾兜底"。
0. 快速上手
- 目标:单会话总 token 不超过服务端
n_ctx,同时尽量保留最近多轮上下文供追问。 - 核心算法:在 producer 线程里,每次 LLM 调用前整轮删除最早历史 + 原位截断巨型 ToolMessage,用 LangGraph
RemoveMessage(id=...)精确修改 checkpoint。 - 预算公式: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) # 下限兜底 - 调整入口:
.env文件SESSION_MAX_TOKENS/LLM_CONTEXT_SIZE。 - 不要做的事:不要单边降 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_SIZE3.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 端到端验证清单
部署/重构后必须实测:
- 不爆:连续追问 6~8 轮后查询 → 日志无
exceed_context_size_error - 多轮可用:追问"刚才那个结果再按年份分" → LLM 能从历史读出上次 SQL
- 数据正确:数据库 5 行 → 表格显示 5 行(不是 LLM 编造的 800 行)
- 日志干净:连续 10 次查询 → 日志中没有 hundreds 条
Row X has 0 elementswarning
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 优化顺序(正确的思考链)
- ✅ 瘦 system(最有效,直接省 system_prompt_token)
- ✅ 减工具集(省 schema_token + 省一次 LLM 调用)
- ✅ 调准 schema 估算常量(避免 budget 算错)
- ✅ 实测算 token 公式(n_ctx − system − tools − reserve)
- ⚠️ 降 n_ctx 是最后手段,且只在历史用不完时使用
- ⚠️ 彻底根治 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_TOKENS | 3000 | [config.py#L100](file:///d:/1sqyai/tangdou/backend/app/core/config.py#L100) | 用户配置的会话预算 |
LLM_CONTEXT_SIZE | 8192 | [config.py#L102](file:///d:/1sqyai/tangdou/backend/app/core/config.py#L102) | 服务端 n_ctx |
_TOOL_SCHEMA_TOKENS | 1500 | [chat_service.py#L197](file:///d:/1sqyai/tangdou/backend/app/services/chat_service.py#L197) | 4 工具 schema 估算 |
_REPLY_RESERVE_TOKENS | 512 | [chat_service.py#L196](file:///d:/1sqyai/tangdou/backend/app/services/chat_service.py#L196) | 本轮回复 + tool_call 余量 |
_MIN_HISTORY_TOKENS | 300 | [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-05 | exceed_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 关键教训
- 先让固定成本变小(system + tools),再让历史预算自然变大
- 不要单边降 n_ctx 来"提速",代价是 LLM 幻觉
- 永远尊重服务端 n_ctx 硬上限,budget = min(用户配置, n_ctx - 固定)