多轮对话与上下文窗口管理
引言:为什么 messages.append 撑不住多轮产品
第 03 篇我们落地了 LLMClient:超时、重试、Token 统计、request_id 日志。第 04 篇我们落地了 PromptRunner:模板渲染、版本管理、把 prompt_version 写进 metadata。
单轮问答已经能稳定跑。但产品一旦加上「多轮对话」,很多团队会回到 Demo 写法------逻辑很简单:追加 user,调 client,再追加 assistant:
python
# 反模式:history 无限增长,无裁剪、无 session 追踪、无成本治理
history.append({"role": "user", "content": user_input})
result = client.chat(history)
history.append({"role": "assistant", "content": result.text})
逻辑没错------messages 数组本来就该带历史 。问题在于:history 会无限增长,而模型的 上下文窗口(context window) 是有限的。
Demo 阶段这完全够用。真实用户聊上 20 轮以后,问题会来得很快:
- API 返回 context length exceeded,直接 400;
- Token 费用线性增长,长会话成本失控;
- 模型「忘记」早期关键信息(已确认的订单号、用户偏好、拒答边界);
- system / few-shot 被挤掉,或每轮重复手写进 history,浪费 Token;
- 无法区分「要长期记住的事实」和「可丢弃的寒暄」;
- 日志只有单次
request_id,没有 session_id 与会话级成本视图。
这些都不是模型变笨了,而是 对话层缺失工程化 的问题。
多轮对话的本质,是在有限的上下文窗口内,为当前这一轮挑选「最值得发送的历史」。
本篇是系列第三篇以代码为主 的实战文:在 LLMClient + PromptRunner 之上,设计 ConversationSession、ContextManager 和可切换的截断策略,把 history 从业务 if/else 抽成可测试的对话层。后续 RAG 的大段 {context}、Agent 的 tool 消息,都会在这个框架里占预算------本篇先把底座打好。
1. 上下文窗口解剖:一次 API 调用里到底装了什么
第 04 篇 PromptRunner.run() 每次调用都会渲染出一组 messages。多轮场景下,最终发给模型的 messages 通常由以下几部分拼接而成:
| 部分 | 来源 | 是否每次发送 | 典型占比 |
|---|---|---|---|
| system + few-shot | PromptTemplate | 是(由 Runner 渲染,应 pin 版本) | 固定 |
| 历史 user / assistant | ConversationSession | 是(经裁剪后) | 可变、易膨胀 |
| 当前 user(含 RAG context) | 本轮输入 + 模板 | 是 | 中~大 |
| tool / function 消息 | Agent 场景 | 是 | 可变(第 07 篇展开) |
上下文窗口 = 模型单次能接收的最大 Token 上限(如 32k、128k)。这不是「记忆容量」,而是 输入预算。
可用给历史的预算,大致是:
text
available_for_history
≈ model_context_limit
− completion_reserve # 预留给模型输出
− system + few-shot # PromptTemplate 固定部分
− current_turn_reserve # 当前轮 user + 可能的 RAG context
观点:先算预算,再决定发什么历史 。不要先把全部 history 塞进去,等 API 报错再删。

2. 会话模型:ConversationSession 与消息存储
多轮对话首先要解决:历史存哪里、怎么隔离、怎么和日志对齐。
2.1 设计原则
| 原则 | 说明 |
|---|---|
| 完整存储,按需发送 | DB / 内存存全量;发给模型前走 ContextManager 裁剪 |
| 会话隔离 | 以 session_id 管理,支持多用户多会话 |
| 元数据可扩展 | created_at、message_type、pinned 等,便于保留策略 |
2.2 核心接口
ConversationSession 的职责:append 新消息 → 提供全量 history → 支持摘要替换。核心逻辑:
append():写入一条Message,超过max_stored_turns时从头部丢弃(仅 MVP 内存策略);get_messages():返回副本,供裁剪策略读取;replace_with_summary():把最早一段原文替换为一条 summary 消息,供 SummarizeAndReplace 策略使用。
python
from dataclasses import dataclass, field
from datetime import datetime, timezone
@dataclass
class Message:
role: str # user / assistant / summary
content: str
metadata: dict = field(default_factory=dict) # pinned, message_type 等
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
class ConversationSession:
def __init__(self, session_id: str, *, max_stored_turns: int = 500):
self.session_id = session_id
self._messages: list[Message] = []
self._max_stored_turns = max_stored_turns
def append(self, message: Message) -> None:
self._messages.append(message)
# MVP:内存存储超过上限时,丢弃最早的消息(生产应落库而非丢弃)
if len(self._messages) > self._max_stored_turns:
self._messages = self._messages[-self._max_stored_turns :]
def get_messages(self) -> list[Message]:
return list(self._messages)
def replace_with_summary(self, up_to_index: int, summary: str) -> None:
"""把 [0, up_to_index) 的原文替换为一条 summary 消息"""
if up_to_index <= 0:
return
summary_msg = Message(
role="assistant",
content=f"[对话摘要] {summary}",
metadata={"message_type": "summary"},
)
self._messages = [summary_msg] + self._messages[up_to_index:]
2.3 存储方式(由简到繁)
- 内存 dict (MVP / 单进程):
session_store: dict[str, ConversationSession] - Redis:会话 TTL、水平扩展
- 数据库:审计、长期留存、合规
2.4 与日志对齐
每轮 LLMClient.chat() 的 metadata 建议增加:
session_idhistory_turns:会话内总消息条数history_sent_turns:裁剪后实际发送的条数
与第 03 篇 request_id、第 04 篇 prompt_version 一起,构成完整追踪链。
3. Token 估算与预算:先算清楚再裁剪
裁剪前需要知道:这些 messages 大概占多少 Token? 否则无法判断「还能塞几轮历史」。
3.1 TokenEstimator
TokenEstimator 的逻辑:优先 tiktoken 精确计数,无依赖时用启发式兜底。
- 尝试加载
tiktoken,按 model 选 encoding; - 失败则用
len(text) // 3(中文略偏保守)估算; count_messages()在每条 content 基础上加 4 Token 格式开销。
python
class TokenEstimator:
def __init__(self, model: str = "gpt-4o-mini"):
self._model = model
self._enc = None
try:
import tiktoken
self._enc = tiktoken.encoding_for_model(model)
except Exception:
pass # 无 tiktoken 时走启发式
def count_text(self, text: str) -> int:
if self._enc:
return len(self._enc.encode(text))
# 启发式:中文为主时 //3 比 //4 略安全
return max(1, len(text) // 3)
def count_messages(self, messages: list[dict]) -> int:
total = 0
for m in messages:
total += 4 # role/name 等格式开销(近似)
total += self.count_text(m.get("content", ""))
return total
3.2 ContextBudget
ContextBudget 把窗口切成几块固定预留,剩余才是历史可用额度:
python
from dataclasses import dataclass
@dataclass
class ContextBudget:
model_context_limit: int = 128_000 # 模型窗口上限
completion_reserve: int = 2_048 # 预留给模型输出
system_reserve: int = 0 # 由实测填入:system + few-shot
current_turn_reserve: int = 4_096 # 当前轮 user + RAG context 预留
def available_for_history(self) -> int:
return max(
0,
self.model_context_limit
- self.completion_reserve
- self.system_reserve
- self.current_turn_reserve,
)
使用方式:ContextManager 渲染 PromptTemplate 后,用 TokenEstimator 实测 prefix(system + few-shot)和 current user,回填 system_reserve 和 current_turn_reserve,再算历史可用额度。
4. 截断策略:三种生产可用的裁剪方式
所有策略实现统一接口,便于业务按场景切换:
python
from abc import ABC, abstractmethod
class TruncationStrategy(ABC):
@abstractmethod
def trim(
self,
history: list[Message],
budget: ContextBudget,
estimator: TokenEstimator,
) -> list[Message]:
...
4.1 策略 A:KeepLastNTurns(保留最近 N 轮)
逻辑:从最新消息往前数,保留最多 N 条消息(约 N/2 轮对话),再检查 Token 预算,超出则从头部继续删。
适合客服短对话,实现最简单。
python
class KeepLastNTurns(TruncationStrategy):
def __init__(self, max_messages: int = 20):
self.max_messages = max_messages # 约 10 轮 user+assistant
def trim(self, history, budget, estimator):
limit = budget.available_for_history()
# 先按条数截取尾部
candidates = history[-self.max_messages :]
# 再按 Token 预算从尾部填充
selected: list[Message] = []
used = 0
for msg in reversed(candidates):
cost = estimator.count_text(msg.content) + 4
if used + cost > limit:
break
selected.insert(0, msg)
used += cost
return selected
4.2 策略 B:TokenBudgetFill(按预算从尾部填充)
逻辑 :不限条数,从最新消息往前累加 Token,直到触达 available_for_history()。
适合长对话、各轮长度不均匀,推荐作为默认策略。
python
class TokenBudgetFill(TruncationStrategy):
def trim(self, history, budget, estimator):
limit = budget.available_for_history()
selected: list[Message] = []
used = 0
for msg in reversed(history):
cost = estimator.count_text(msg.content) + 4
if used + cost > limit:
break
selected.insert(0, msg)
used += cost
return selected
4.3 策略 C:SummarizeAndReplace(摘要压缩旧历史)
逻辑 :当全量 history 的 Token 超过 trigger_tokens 时,把最早一段原文交给 LLM 生成摘要,调用 replace_with_summary() 替换------后续裁剪在更短的 history 上进行。
摘要 Prompt 要点:保留用户意图、已确认事实、未完成任务;丢弃寒暄。
python
SUMMARY_SYSTEM = "你是会话摘要助手。把对话历史压缩为简短中文摘要,保留诉求、已确认事实、未完成任务。"
class SummarizeAndReplace(TruncationStrategy):
def __init__(self, client, trigger_tokens: int = 6_000, summarize_count: int = 6):
self._client = client
self._trigger_tokens = trigger_tokens
self._summarize_count = summarize_count # 每次摘要最早几条
self._inner = TokenBudgetFill()
def _total_tokens(self, history, estimator) -> int:
return sum(estimator.count_text(m.content) + 4 for m in history)
def _maybe_summarize(self, session: "ConversationSession", history, estimator):
if self._total_tokens(history, estimator) <= self._trigger_tokens:
return history
head = history[: self._summarize_count]
transcript = "\n".join(f"{m.role}: {m.content}" for m in head)
messages = [
{"role": "system", "content": SUMMARY_SYSTEM},
{"role": "user", "content": transcript},
]
result = self._client.chat(messages, metadata={"scene": "history_summary"})
session.replace_with_summary(self._summarize_count, result.text)
return session.get_messages()
def trim(self, history, budget, estimator, session=None):
# session 传入时才做摘要;纯 trim 时退化为 TokenBudgetFill
if session is not None:
history = self._maybe_summarize(session, history, estimator)
return self._inner.trim(history, budget, estimator)
注意:摘要本身消耗 Token;摘要错误会污染后续------关键事实应 metadata.pinned=True,在 RetentionPolicy 中永不裁剪。
4.4 策略对比
| 策略 | 实现难度 | 记忆效果 | 成本 | 适用场景 |
|---|---|---|---|---|
| KeepLastNTurns | 低 | 差(丢早期) | 低 | 短对话客服 |
| TokenBudgetFill | 中 | 中 | 中 | 通用默认 |
| SummarizeAndReplace | 高 | 较好 | 高(额外 LLM 调用) | 长会话助手 |

5. 分场景保留:不是所有消息都一样重要
截断不是无脑删最早的消息。发送前应做 RetentionPolicy 过滤:
5.1 Pin 规则(永不裁剪)
- PromptTemplate 渲染的 system / few-shot (第 04 篇,由 Runner 负责,不进 Session history)
- 用户显式标记的长期记忆:
metadata.pinned = True - 已生成的 summary 消息:
message_type == "summary"
5.2 可丢弃
- 纯寒暄、重复确认:
message_type == "discardable" - 已被摘要覆盖的旧轮次原文(由
replace_with_summary处理)
5.3 条件保留
- 最近 1 轮内的 tool 结果(第 07 篇 Function Calling 展开)
- RAG 的
{context}只进当前轮 user,不写入 Session 永久 history------否则大段检索结果每轮重复发送,Token 爆炸
RetentionPolicy 逻辑:pinned / summary 永远保留;discardable 直接丢掉;其余进入截断策略。
python
class RetentionPolicy:
def filter(self, history: list[Message]) -> list[Message]:
kept: list[Message] = []
for m in history:
if m.metadata.get("pinned"):
kept.append(m)
continue
if m.metadata.get("message_type") == "summary":
kept.append(m)
continue
if m.metadata.get("message_type") == "discardable":
continue
kept.append(m)
return kept
6. 对话流水线:Session → ContextManager → PromptRunner → LLMClient
完整数据流:
text
用户输入 + session_id
→ ConversationSession.append(user)
→ RetentionPolicy.filter(history)
→ TruncationStrategy.trim(history, budget)
→ PromptTemplate 渲染 system + few-shot + 当前 user
→ 拼接:[prefix] + [trimmed_history] + [current_user]
→ LLMClient.chat(messages, metadata={session_id, ...})
→ append assistant 到 Session
→ CostTracker.record(session_id, usage)
与第 04 篇的边界:
- PromptTemplate 管 system / few-shot / 当前 user 槽位(含
{context}) - ContextManager 管历史裁剪与拼接顺序
- 不要把 history 写进
{context}占位符

6.1 ContextManager
ContextManager 是多轮对话的 唯一 messages 组装入口。逻辑:
- 用 Registry 取模板并
render(**variables),得到[system, few-shot..., current_user]; - 实测 prefix 与 current_user 的 Token,更新 budget;
- 对 Session history 做 RetentionPolicy → TruncationStrategy;
- 拼接:
prefix + trimmed_history_dicts + [current_user]; - 返回 messages 与 stats(发送条数、裁剪 Token 估算)。
python
class ContextManager:
def __init__(self, registry, estimator: TokenEstimator):
self._registry = registry
self._estimator = estimator
def build_messages(
self,
session: ConversationSession,
*,
prompt_id: str,
version: str | None,
variables: dict,
strategy: TruncationStrategy,
budget: ContextBudget,
retention: RetentionPolicy | None = None,
) -> tuple[list[dict], dict]:
retention = retention or RetentionPolicy()
template = self._registry.get(prompt_id, version)
# 1. 渲染模板:最后一条是当前 user,前面是 system + few-shot
rendered = template.render(**variables)
prefix = rendered[:-1]
current_user = rendered[-1]
# 2. 实测 Token,回填 budget 预留
budget.system_reserve = self._estimator.count_messages(prefix)
budget.current_turn_reserve = self._estimator.count_messages([current_user])
# 3. 裁剪历史
full_history = session.get_messages()
filtered = retention.filter(full_history)
kwargs = {}
if isinstance(strategy, SummarizeAndReplace):
kwargs["session"] = session
trimmed = strategy.trim(filtered, budget, self._estimator, **kwargs)
history_dicts = [{"role": m.role, "content": m.content} for m in trimmed]
messages = prefix + history_dicts + [current_user]
# 4. 统计信息,写入 metadata / 日志
sent_tokens = self._estimator.count_messages(messages)
full_tokens = self._estimator.count_messages(
[{"role": m.role, "content": m.content} for m in full_history]
)
stats = {
"session_id": session.session_id,
"history_turns": len(full_history),
"history_sent_turns": len(trimmed),
"truncated_tokens": max(0, full_tokens - sent_tokens),
"strategy_name": strategy.__class__.__name__,
"estimated_prompt_tokens": sent_tokens,
}
return messages, stats
7. 成本与会话治理:长对话不能只谈效果不谈钱
第 03 篇在单次 request 级别记录了 prompt_tokens / completion_tokens。多轮场景需要 会话级汇总。
7.1 会话指标
session_total_tokens:累计 Tokensession_total_cost:单价 × Token(可按 model 分档)avg_prompt_tokens_per_turn:是否因 history 膨胀而上升
7.2 治理手段
| 手段 | 说明 |
|---|---|
| 单会话 Token 上限 | 超限提示开新会话,或触发 SummarizeAndReplace |
| 单用户日限额 | 防滥用 |
| 降级策略 | 接近上限时切更小模型,或切换更激进截断 |
7.3 CostTracker 与日志字段
CostTracker 按 session_id 累加每次调用的 usage:
python
from dataclasses import dataclass, field
@dataclass
class SessionCost:
total_prompt_tokens: int = 0
total_completion_tokens: int = 0
turn_count: int = 0
@property
def total_tokens(self) -> int:
return self.total_prompt_tokens + self.total_completion_tokens
class CostTracker:
def __init__(self, *, session_token_limit: int | None = 50_000):
self._sessions: dict[str, SessionCost] = {}
self._session_token_limit = session_token_limit
def record(self, session_id: str, usage) -> SessionCost:
cost = self._sessions.setdefault(session_id, SessionCost())
cost.total_prompt_tokens += getattr(usage, "prompt_tokens", 0) or 0
cost.total_completion_tokens += getattr(usage, "completion_tokens", 0) or 0
cost.turn_count += 1
return cost
def is_over_limit(self, session_id: str) -> bool:
if self._session_token_limit is None:
return False
cost = self._sessions.get(session_id)
return cost is not None and cost.total_tokens >= self._session_token_limit
日志扩展示例:
json
{
"request_id": "...",
"session_id": "...",
"history_turns": 18,
"history_sent_turns": 8,
"truncated_tokens": 4200,
"strategy_name": "TokenBudgetFill",
"prompt_tokens": 3100,
"completion_tokens": 180
}
8. 完整代码串联:对话层 + Prompt 层 + LLMClient
下面把前面各节串成一个可运行精简版。整体数据流:
text
SessionStore → ContextManager.build_messages() → LLMClient.chat()
→ session.append(assistant) → CostTracker.record()
假设第 03 篇 LLMClient、第 04 篇 PromptTemplate / PromptRegistry 已存在。建议目录:
text
llm/client.py
prompts/runner.py
conversation/core.py # 本篇合并版
核心 conversation/core.py(合并版便于阅读;生产可按文件拆分):
python
import json
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime, timezone
# ---------- 复用第 04 篇 Prompt 层(此处内联最小版) ----------
@dataclass
class PromptTemplate:
id: str
version: str
system: str
user_template: str
few_shot: list[dict] = field(default_factory=list)
def render(self, **variables) -> list[dict]:
messages = [{"role": "system", "content": self.system.strip()}]
messages.extend(self.few_shot)
messages.append({"role": "user", "content": self.user_template.format(**variables).strip()})
return messages
class PromptRegistry:
def __init__(self):
self._store: dict[str, dict[str, PromptTemplate]] = {}
def register(self, t: PromptTemplate) -> None:
self._store.setdefault(t.id, {})[t.version] = t
def get(self, prompt_id: str, version: str | None = None) -> PromptTemplate:
versions = self._store[prompt_id]
if version is None:
version = sorted(versions.keys())[-1]
return versions[version]
# ---------- 1. 会话消息 ----------
@dataclass
class Message:
role: str
content: str
metadata: dict = field(default_factory=dict)
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
class ConversationSession:
def __init__(self, session_id: str, *, max_stored_turns: int = 500):
self.session_id = session_id
self._messages: list[Message] = []
self._max_stored_turns = max_stored_turns
def append(self, message: Message) -> None:
self._messages.append(message)
if len(self._messages) > self._max_stored_turns:
self._messages = self._messages[-self._max_stored_turns :]
def get_messages(self) -> list[Message]:
return list(self._messages)
def replace_with_summary(self, up_to_index: int, summary: str) -> None:
if up_to_index <= 0:
return
summary_msg = Message(
role="assistant",
content=f"[对话摘要] {summary}",
metadata={"message_type": "summary"},
)
self._messages = [summary_msg] + self._messages[up_to_index:]
class SessionStore:
"""内存 session 存储,生产可换 Redis / DB"""
def __init__(self):
self._sessions: dict[str, ConversationSession] = {}
def get_or_create(self, session_id: str) -> ConversationSession:
if session_id not in self._sessions:
self._sessions[session_id] = ConversationSession(session_id)
return self._sessions[session_id]
# ---------- 2. Token 估算与预算 ----------
class TokenEstimator:
def __init__(self, model: str = "gpt-4o-mini"):
self._enc = None
try:
import tiktoken
self._enc = tiktoken.encoding_for_model(model)
except Exception:
pass
def count_text(self, text: str) -> int:
if self._enc:
return len(self._enc.encode(text))
return max(1, len(text) // 3)
def count_messages(self, messages: list[dict]) -> int:
return sum(self.count_text(m.get("content", "")) + 4 for m in messages)
@dataclass
class ContextBudget:
model_context_limit: int = 8_192
completion_reserve: int = 512
system_reserve: int = 0
current_turn_reserve: int = 512
def available_for_history(self) -> int:
return max(
0,
self.model_context_limit
- self.completion_reserve
- self.system_reserve
- self.current_turn_reserve,
)
# ---------- 3. 截断策略 ----------
class TruncationStrategy(ABC):
@abstractmethod
def trim(self, history, budget, estimator, **kwargs) -> list[Message]:
...
class TokenBudgetFill(TruncationStrategy):
def trim(self, history, budget, estimator, **kwargs):
limit = budget.available_for_history()
selected, used = [], 0
for msg in reversed(history):
cost = estimator.count_text(msg.content) + 4
if used + cost > limit:
break
selected.insert(0, msg)
used += cost
return selected
class KeepLastNTurns(TruncationStrategy):
def __init__(self, max_messages: int = 10):
self.max_messages = max_messages
def trim(self, history, budget, estimator, **kwargs):
inner = TokenBudgetFill()
return inner.trim(history[-self.max_messages :], budget, estimator)
# ---------- 4. 保留策略 + ContextManager ----------
class RetentionPolicy:
def filter(self, history: list[Message]) -> list[Message]:
out = []
for m in history:
if m.metadata.get("pinned"):
out.append(m)
continue
if m.metadata.get("message_type") == "summary":
out.append(m)
continue
if m.metadata.get("message_type") == "discardable":
continue
out.append(m)
return out
class ContextManager:
def __init__(self, registry: PromptRegistry, estimator: TokenEstimator):
self._registry = registry
self._estimator = estimator
def build_messages(
self, session, *, prompt_id, version, variables, strategy, budget,
retention=None,
):
retention = retention or RetentionPolicy()
template = self._registry.get(prompt_id, version)
rendered = template.render(**variables)
prefix, current_user = rendered[:-1], rendered[-1]
budget.system_reserve = self._estimator.count_messages(prefix)
budget.current_turn_reserve = self._estimator.count_messages([current_user])
full = session.get_messages()
trimmed = strategy.trim(retention.filter(full), budget, self._estimator)
messages = prefix + [{"role": m.role, "content": m.content} for m in trimmed] + [current_user]
stats = {
"session_id": session.session_id,
"history_turns": len(full),
"history_sent_turns": len(trimmed),
"strategy_name": strategy.__class__.__name__,
}
return messages, stats
@dataclass
class SessionCost:
total_prompt_tokens: int = 0
total_completion_tokens: int = 0
turn_count: int = 0
@property
def total_tokens(self) -> int:
return self.total_prompt_tokens + self.total_completion_tokens
class CostTracker:
def __init__(self, *, session_token_limit: int | None = 50_000):
self._sessions: dict[str, SessionCost] = {}
self._session_token_limit = session_token_limit
def record(self, session_id: str, usage) -> SessionCost:
c = self._sessions.setdefault(session_id, SessionCost())
c.total_prompt_tokens += getattr(usage, "prompt_tokens", 0) or 0
c.total_completion_tokens += getattr(usage, "completion_tokens", 0) or 0
c.turn_count += 1
return c
def is_over_limit(self, session_id: str) -> bool:
if self._session_token_limit is None:
return False
c = self._sessions.get(session_id)
return c is not None and c.total_tokens >= self._session_token_limit
# ---------- 5. Mock:演示长 history 裁剪 ----------
@dataclass
class MockUsage:
prompt_tokens: int = 100
completion_tokens: int = 50
@dataclass
class MockChatResult:
text: str
usage: MockUsage = field(default_factory=MockUsage)
request_id: str = "mock-001"
class MockLLMClient:
def chat(self, messages, *, metadata=None, **kwargs):
return MockChatResult(text="好的,已收到。", usage=MockUsage(prompt_tokens=len(str(messages)) // 4))
def _seed_long_history(session: ConversationSession, n: int = 30):
for i in range(n):
session.append(Message(role="user", content=f"用户第{i}轮:请问订单 #{1000+i} 的状态?"))
session.append(Message(role="assistant", content=f"助手第{i}轮:订单 #{1000+i} 正在处理中。"))
if __name__ == "__main__":
registry = PromptRegistry()
registry.register(PromptTemplate(
id="chat", version="1.0.0",
system="你是客服助手。结合对话历史回答用户。",
user_template="用户问题:{question}",
))
store = SessionStore()
session = store.get_or_create("sess-demo")
_seed_long_history(session, n=30)
ctx = ContextManager(registry, TokenEstimator())
budget = ContextBudget(model_context_limit=2_048, completion_reserve=256, current_turn_reserve=128)
messages, stats = ctx.build_messages(
session,
prompt_id="chat",
version="1.0.0",
variables={"question": "我最早问的那个订单现在怎样了?"},
strategy=TokenBudgetFill(),
budget=budget,
)
print(json.dumps({
"history_stored": stats["history_turns"],
"history_sent": stats["history_sent_turns"],
"messages_out": len(messages),
"roles": [m["role"] for m in messages],
}, ensure_ascii=False, indent=2))
# 典型输出:history_stored=60, history_sent≈6~12, messages_out 明显小于全量
生产环境业务调用:把 MockLLMClient 换成第 03 篇 LLMClient.from_env(),并传入真实 session_id:
python
store = SessionStore()
session = store.get_or_create(session_id)
session.append(Message(role="user", content=user_input))
messages, stats = context_manager.build_messages(
session,
prompt_id="kb_qa",
version="1.1.0",
variables={"question": user_input, "context": retrieved_docs}, # RAG context 仅当前轮
strategy=TokenBudgetFill(),
budget=ContextBudget(model_context_limit=32_000),
)
result = client.chat(messages, metadata={**stats, "prompt_id": "kb_qa"})
session.append(Message(role="assistant", content=result.text))
cost_tracker.record(session_id, result.usage)
if cost_tracker.is_over_limit(session_id):
# 提示用户新开对话,或切换 SummarizeAndReplace
...
没有 API Key 时,用 MockLLMClient 先跑通 Session → 裁剪 → 拼接全链路;接入真实模型后再观察 history_sent_turns 与 Token 曲线。
9. 常见误区
误区 1:「把全部 history 发给模型最保险」
会爆窗、费钱,噪声多反而降低回答质量。应存储全量、发送裁剪后子集。
误区 2:「截断就是删掉最早的消息」
应结合 Token 预算、摘要、pin 规则。关键订单号等事实应 pinned,或走 SummarizeAndReplace。
误区 3:「system 每次都手写进 history」
system 应由 PromptTemplate 渲染,避免版本漂移和重复 Token。
误区 4:「摘要一劳永逸」
摘要会丢细节、会出错;需要触发阈值,关键事实应 pin。
误区 5:「只记 request_id 就够」
多轮排查需要 session_id + 每轮 history_sent_turns + 裁剪策略名。
误区 6:「RAG context 也存进永久 history」
大段检索结果每轮重复,Token 爆炸。{context} 只进当前轮 user,不 append 进 Session。
10. 本篇练习建议
- 为现有聊天接口增加
session_id,用ConversationSession替换裸listhistory。 - 实现
TokenBudgetFill,打印裁剪前后 messages 条数与估算 Token。 - 设单会话 Token 上限 8k,超限触发摘要或提示新开对话。
- 在日志中增加
session_id、history_sent_turns、truncated_tokens。
11. 下篇预告
第 06 篇:模型选型
在接入层、Prompt 层、对话层就绪后,讨论不同任务如何选模型:效果、成本、延迟、上下文长度与结构化输出能力的权衡。
12. 讨论
你的多轮对话现在怎么存历史?有没有遇到过 context 超限或费用暴涨?你们更倾向「裁掉旧消息」还是「摘要压缩」?
欢迎在评论区说说你的现状,后续文章会优先覆盖高频问题。
话题标签:
#AI应用开发 #大模型 #LLM #Python #对话系统
