【无标题】

多轮对话与上下文窗口管理


引言:为什么 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 之上,设计 ConversationSessionContextManager 和可切换的截断策略,把 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_atmessage_typepinned 等,便于保留策略

2.2 核心接口

ConversationSession 的职责:append 新消息 → 提供全量 history → 支持摘要替换。核心逻辑:

  1. append():写入一条 Message,超过 max_stored_turns 时从头部丢弃(仅 MVP 内存策略);
  2. get_messages():返回副本,供裁剪策略读取;
  3. 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 存储方式(由简到繁)

  1. 内存 dict (MVP / 单进程):session_store: dict[str, ConversationSession]
  2. Redis:会话 TTL、水平扩展
  3. 数据库:审计、长期留存、合规

2.4 与日志对齐

每轮 LLMClient.chat() 的 metadata 建议增加:

  • session_id
  • history_turns:会话内总消息条数
  • history_sent_turns:裁剪后实际发送的条数

与第 03 篇 request_id、第 04 篇 prompt_version 一起,构成完整追踪链。


3. Token 估算与预算:先算清楚再裁剪

裁剪前需要知道:这些 messages 大概占多少 Token? 否则无法判断「还能塞几轮历史」。

3.1 TokenEstimator

TokenEstimator 的逻辑:优先 tiktoken 精确计数,无依赖时用启发式兜底

  1. 尝试加载 tiktoken,按 model 选 encoding;
  2. 失败则用 len(text) // 3(中文略偏保守)估算;
  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_reservecurrent_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 组装入口。逻辑:

  1. 用 Registry 取模板并 render(**variables),得到 [system, few-shot..., current_user]
  2. 实测 prefix 与 current_user 的 Token,更新 budget;
  3. 对 Session history 做 RetentionPolicy → TruncationStrategy;
  4. 拼接:prefix + trimmed_history_dicts + [current_user]
  5. 返回 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:累计 Token
  • session_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. 本篇练习建议

  1. 为现有聊天接口增加 session_id,用 ConversationSession 替换裸 list history。
  2. 实现 TokenBudgetFill,打印裁剪前后 messages 条数与估算 Token。
  3. 设单会话 Token 上限 8k,超限触发摘要或提示新开对话。
  4. 在日志中增加 session_idhistory_sent_turnstruncated_tokens

11. 下篇预告

第 06 篇:模型选型

在接入层、Prompt 层、对话层就绪后,讨论不同任务如何选模型:效果、成本、延迟、上下文长度与结构化输出能力的权衡。


12. 讨论

你的多轮对话现在怎么存历史?有没有遇到过 context 超限或费用暴涨?你们更倾向「裁掉旧消息」还是「摘要压缩」?

欢迎在评论区说说你的现状,后续文章会优先覆盖高频问题。


话题标签:

#AI应用开发 #大模型 #LLM #Python #对话系统

相关推荐
盖立克思GEO研究院1 小时前
【2026】海外 B2B 工厂 AI 询盘破局:独立站 + GEO 双驱动,ChatGPT/Gemini1 个月精准获客案例拆解
人工智能·chatgpt
东坡肘子1 小时前
当每一次写入都有了新价格 -- 肘子的 Swift 周报 #144
人工智能·swiftui·swift
水龙吟啸1 小时前
华为2026.6.3机考选择题+编程题【速刷敲黑板】
人工智能·深度学习·算法·华为
贺国亚2 小时前
MCP生产宿主与Tool-Schema治理
prompt
折哥的程序人生 · 物流技术专研2 小时前
Java 23 种设计模式:从踩坑到精通 | 番外:工厂方法 vs 抽象工厂 —— 从单产品到产品族,架构如何演进?
java·spring·java面试·抽象工厂·工厂方法·java设计模式·从踩坑到精通
爸爸6192 小时前
30 原始 SQL 查询与聚合统计:querySql 实战
数据库·sql·华为·harmonyos·鸿蒙系统
砍材农夫2 小时前
spring|spring event|Spring内置事件驱动编程模型
java·数据库·spring
郭泽斌之心2 小时前
让别人临时接管你的 AI 助手:分级授权的远程控制怎么设计
人工智能·深度学习·量化交易·ea·mt5·fay数字人·easydeal
卷福同学2 小时前
AI编程出海第一步:别急着写代码,先找到老外真正愿意付费的需求
人工智能·后端·面试