首页/Agent 核心/会话管理与 JSON 输出
Agent 核心 · 第 06 章

会话管理与 JSON 输出

第 5 章把模型跑通了:本地推理服务就位,system / user / assistant 的消息体系也建立起来。但离"用好"还差两块拼图。其一是跨轮次的记忆管理——所谓"连续对话",其实是你的代码在维护一个不断增长的…

约 24 分钟9,782 字15 个代码示例

第 5 章把模型跑通了:本地推理服务就位,system / user / assistant 的消息体系也建立起来。但离"用好"还差两块拼图。其一是跨轮次的记忆管理——所谓"连续对话",其实是你的代码在维护一个不断增长的 messages 数组,数组每长一分,费用涨一分、延迟高一分,直到撞上上下文窗口的天花板;其二是让输出可被程序消费——模型天生输出自由文本,人能读,程序没法稳定解析。本章把这两块补齐:前半部分把会话的"持续经营"工程化,封装 Session 类、算清 token 账、掌握三种历史裁剪策略、吃透缓存命中的原理;后半部分给出结构化输出从脆弱到稳固的完整方案链。这是"第二章 怎么用好它"的收官——从此模型不只是"聊得来",还要"管得住、读得出"。

1. 最小会话:20 行的"记忆幻觉"

先封装一个能用的 Session,把第 5 章的数组维护逻辑收进类里:

from openai import OpenAI

client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")

class Session:
    def __init__(self, system: str, model: str = "qwen3:4b"):
        self.model = model
        self.messages = [{"role": "system", "content": system}]

    def chat(self, user_input: str) -> str:
        self.messages.append({"role": "user", "content": user_input})
        resp = client.chat.completions.create(
            model=self.model,
            messages=self.messages,        # 全量历史随请求重发
        )
        answer = resp.choices[0].message.content
        self.messages.append({"role": "assistant", "content": answer})
        return answer

# 使用
s = Session(system="你是一个简洁的 Python 助教")
print(s.chat("什么是递归?"))
print(s.chat("给我一个例子"))          # 它"记得"上文在聊递归
print(len(s.messages))                  # 5 条:system + 2轮 × (user+assistant)

这个类是本章前半部分的主角,后面逐步给它加能力。先注意它的成本结构:第 N 轮请求时,之前所有轮次的 user+assistant 消息都要作为输入 token 重新计费(第 4 章 token 经济学)。对话越长越贵,这不是 API 的设计缺陷,是自回归架构的物理现实。

2. 算清 token 账:事前估算,事后校准

管理上下文的前提是计量。两个手段,一个事后、一个事前:

事后(精确):从响应的 usage 里读真实值(第 5 章见过),累计起来就是会话的真实开销:

def chat(self, user_input):
    ...
    self.total_tokens += resp.usage.total_tokens

事前(估算):发送前预判会不会超窗,只能估算。第 1 章的换算率在这里落地:

def estimate_tokens(text: str) -> int:
    """粗略估算中英混合文本的 token 数(±15% 误差)"""
    # 英文:约 4 字符/token;中文:约 1.5 字符/token
    cn = sum('\u4e00' <= c <= '\u9fff' for c in text)
    en = len(text) - cn
    return int(cn / 1.5 + en / 4)

def total_messages_tokens(messages: list[dict]) -> int:
    # 每条消息有少量固定开销(role 标记等),约 4 token
    return sum(estimate_tokens(m["content"]) + 4 for m in messages)

本地开发可以做得更准——用模型自己的分词器。Ollama 的响应里带有精确计数,只让它生成 1 个 token 就能拿到输入侧的账单:

# uv add ollama
import ollama

def count_precise(text: str, model: str = "qwen3:4b") -> int:
    # 只生成 1 个 token 即停:prompt_eval_count 就是输入文本的精确 token 数
    resp = ollama.generate(
        model=model, prompt=text,
        options={"num_predict": 1},
    )
    return resp.prompt_eval_count or 0

生产中推荐组合拳:发送前用估算值做粗筛,发送后用 usage 真值校准水位线。

3. 历史裁剪:三种策略

窗口是有限的(第 4 章),历史无限增长必然出事。三种策略,复杂度和效果递增:

3.1 截断(保头保尾)

最朴素:优先保留 system(system + 最近 K 轮),丢弃中间旧轮次。

def truncate(self, max_msgs: int = 10):
    """保留 system 和最近 max_msgs 条"""
    system, rest = self.messages[0], self.messages[1:]
    self.messages = [system] + rest[-max_msgs:]

def chat(self, user_input: str):
    ...
    if total_messages_tokens(self.messages) > self.window_budget:
        self.truncate()

优点:一行逻辑,O(1) 开销。缺点:硬切断会丢关键信息(用户在第一轮说的偏好、早前布置的任务)。适合闲聊类短会话。

3.2 滑动窗口 + 摘要压缩

丢之前先"榨汁":把将被丢弃的历史让模型压缩成摘要,替换原文,常驻 system 附近:

def summarize_old(self) -> str:
    """把旧历史压缩为一段摘要"""
    resp = client.chat.completions.create(
        model=self.model,
        messages=[
            {"role": "system",
             "content": "将以下对话压缩为不超过150字的关键信息摘要,保留:用户目标、已确认的事实、尚未完成的任务。"},
            {"role": "user", "content": repr(self.messages)},
        ],
        temperature=0.2,
    )
    return resp.choices[0].message.content

def compact(self, keep_recent: int = 4):
    """压缩旧历史,保留最近几轮原文"""
    system = self.messages[0]
    old, recent = self.messages[1:-keep_recent], self.messages[-keep_recent:]
    if not old:
        return
    summary = self.summarize_old()
    self.messages = [
        system,
        {"role": "system", "content": f"[会话历史摘要]\n{summary}"},
        *recent,
    ]

这是性价比最高的主流方案:信息以压缩形式保留,近期交互保持原文精度。注意摘要本身也是一次 LLM 调用(有损、有成本),而且压缩比要克制——摘要塞太满,模型容易把摘要当圣旨。

3.3 检索式(记忆外挂)

长会话的正确终局:历史存进向量库(第 1 章的 embedding + 余弦相似度),每轮只检索相关片段注入上下文。这已经是"长期记忆"的范畴,第 15 章 Agent 记忆专门展开,此处按下不表。

3.4 策略选择

策略 成本 信息保留 适用
截断 零 差 短会话、闲聊
摘要压缩 每次压缩一次调用 中(有损) 多轮任务会话
检索式 向量库 + 每轮检索 按相关性 跨天/长期记忆

一个容易忽略的细节:裁剪必须在"成对边界"上进行。从一对 user/assistant 的中间切开,会留下"有问无答"或"有答无问"的畸形历史——多数模型能容忍,但第 7 章之后的历史里会出现 tool 调用对,那种历史被拦腰截断会直接报错。

4. 缓存命中:省钱的隐形杠杆

第 4 章埋的线在此展开。回顾原理:注意力计算需要全部历史位置的 K、V 向量(第 3 章),如果两次请求的前缀完全相同,这些 K/V 可以直接复用——省掉整个 prefill 阶段的重算。这就是提示词缓存(prompt caching)。

由此推出黄金法则——静态内容在前,动态内容在后:

❌ 差的排列(每轮前缀都变,缓存全失效):
   [含当前时间戳的system] [历史...] [新输入]

✅ 好的排列(前缀稳定,命中到最新一条):
   [固定system] [历史(追加式)] [新输入]

各家的实现形态:

厂商 机制 要点
Ollama 自动前缀缓存 同一客户端连续请求自动命中;keep_alive 参数控制模型驻留内存,避免反复加载
DeepSeek 自动上下文缓存 无需任何代码改动,命中部分按约 1/10 计价
OpenAI / Anthropic 显式标记缓存 前缀稳定过阈值(如 1024 token)自动缓存,命中打折

对代码的影响只有一条:系统提示词和工具定义(第 7 章)保持字节级稳定——包括消息顺序。把"当前时间"这类每轮变化的信息放到最后一条 user 消息里,而不是 system 里(第 5 章的模板化要为此让步)。

# 时间戳放 user 消息尾部,保住前缀缓存
messages = [
    {"role": "system", "content": FIXED_SYSTEM},        # 字节级稳定
    *history,                                             # 只追加,不改动
    {"role": "user", "content": f"{q}\n\n[当前时间 {now}]"},
]

5. 生产级 Session 的完整形态

把本章前半部分的所有能力组装起来,这就是第三章 Agent 实战的会话地基:

from openai import OpenAI

class Session:
    def __init__(self, system: str, model: str = "qwen3:4b",
                 window_budget: int = 6000, keep_recent: int = 4):
        self.model = model
        self.window_budget = window_budget
        self.keep_recent = keep_recent
        self.messages = [{"role": "system", "content": system}]
        self.total_tokens = 0

    def chat(self, user_input: str) -> str:
        self.messages.append({"role": "user", "content": user_input})
        self._enforce_budget()                        # 每轮发送前检查
        resp = client.chat.completions.create(
            model=self.model, messages=self.messages,
        )
        answer = resp.choices[0].message.content
        self.messages.append({"role": "assistant", "content": answer})
        self.total_tokens += resp.usage.total_tokens
        return answer

    def _enforce_budget(self):
        if total_messages_tokens(self.messages) <= self.window_budget:
            return
        self.compact(keep_recent=self.keep_recent)     # 摘要压缩
        # 压缩后仍超(极端情况),退化为硬截断
        if total_messages_tokens(self.messages) > self.window_budget:
            self.truncate(max_msgs=self.keep_recent * 2)

注意 chat 里 finish_reason 没检查——留给你:参照第 5 章补上 length 截断处理,这是很好的练习。

至此,你已经能让模型"持续对话"了。但有个问题越来越刺眼:模型所有输出都是自由文本——人能读,程序没法稳定解析。本章后半部分就补上这块拼图。

6. 问题:自由文本是程序的地雷

模型天生输出自由文本,但程序需要的是机器可解析的数据。这个"翻译层"是 Agent 的命门:第 7 章的 tool calling、第 8 章的 Agent 循环、第 15 章的评测打分,全都建立在"模型输出能被代码可靠消费"之上。

你要做"用户情绪分类"功能,最直觉的做法是提示词里写"请返回 JSON":

resp = client.chat.completions.create(
    model="qwen3:4b",
    messages=[{"role": "user",
               "content": '分析这句话的情绪,返回JSON:{"sentiment": "正面|负面|中性", "score": 0到1}\n用户输入:这个产品用起来太糟心了'}],
)
print(resp.choices[0].message.content)

你会收获五花八门的"惊喜":

好的!以下是分析结果:          ← 有人在说话
{"sentiment": "负面", "score": 0.85}   ← 有前缀废话
```json
{"sentiment": "负面", "score": 0.85}   ← markdown 代码块包裹
{"sentiment": "负面", "score": 85}      ← score 忘了是 0~1,给成百分制
{"sentiment": "负向", "score": 0.85}   ← 枚举值自由发挥

直接 json.loads 一把梭,程序当场崩溃。根因在第 4 章:模型是续写统计机器,"格式正确"只是高概率倾向,不是保证。要工程级可靠,必须用约束 + 校验 + 重试的组合拳。

7. 第一道防线:JSON 模式

多数推理服务提供 JSON 模式——通过 response_format 开启:

resp = client.chat.completions.create(
    model="qwen3:4b",
    messages=[
        {"role": "system", "content": "你是情感分析引擎,只输出JSON。"},
        {"role": "user", "content": "分析:这个产品用起来太糟心了"},
    ],
    response_format={"type": "json_object"},   # JSON 模式
)

它保证的是语法合法的 JSON(引号闭合、括号配对)。但注意它不保证内容符合你的 schema——字段名可能变、枚举可能自由发挥、数值范围可能漂移。语法关过了,语义关还没过。

8. 第二道防线:Pydantic 校验

用 Pydantic 把期望的结构声明成类型,输出必须过审才算数:

# uv add pydantic
from pydantic import BaseModel, Field, ValidationError
from typing import Literal

class Sentiment(BaseModel):
    sentiment: Literal["正面", "负面", "中性"]          # 枚举锁死
    score: float = Field(ge=0, le=1)                     # 范围锁死
    keywords: list[str] = Field(default_factory=list)    # 可选字段给默认值

data = Sentiment.model_validate_json(resp.choices[0].message.content)

任何不符合约定的输出(枚举外值、score=85、缺字段)都会抛 ValidationError,把"看起来对"变成"可证明对"。

但校验只负责"发现问题",问题出现后怎么办?——把错误喂回去重试。

9. 第三道防线:校验-重试循环

这个模式是结构化输出的核心工程模式(也是第 7 章工具调用可靠性的雏形):

import json
from openai import OpenAI

client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")

def extract_json(text: str) -> dict:
    """从模型输出中鲁棒地提取 JSON 对象"""
    # 剥掉 markdown 代码块围栏
    if "```" in text:
        text = text.split("```")[1]
        if text.startswith("json"):
            text = text[4:]
    # 取第一个 { 到最后一个 } 之间的内容(跳过前后废话)
    start, end = text.find("{"), text.rfind("}")
    return json.loads(text[start:end + 1])

def ask_structured(question: str, retries: int = 2) -> Sentiment:
    messages = [
        {"role": "system",
         "content": "你是情感分析引擎。只输出一个JSON对象,结构:"
                    '{"sentiment":"正面|负面|中性","score":0到1的小数,"keywords":[]}'},
        {"role": "user", "content": question},
    ]
    for attempt in range(retries + 1):
        resp = client.chat.completions.create(
            model="qwen3:4b",
            messages=messages,
            temperature=0.1,                      # 结构化任务用低温(第4章)
        )
        try:
            return Sentiment.model_validate(extract_json(resp.choices[0].message.content))
        except (json.JSONDecodeError, ValidationError) as e:
            # 关键:把错误信息作为新消息喂回去,让模型自我修正
            messages += [
                {"role": "assistant", "content": resp.choices[0].message.content},
                {"role": "user", "content": f"输出不符合要求:{e}\n请严格按要求重新输出,只输出JSON。"},
            ]
    raise RuntimeError(f"{retries + 1} 次尝试均失败")

三个细节值得琢磨:

  1. 错误信息是最有效的提示词。ValidationError 的报文("score: Input should be less than or equal to 1")对模型是精确的修正指令
  2. extract_json 的鲁棒解析是无奈但必要的防御——即使有 JSON 模式,也常遇到模型在 JSON 前后"加戏"
  3. 重试次数要设上限:本地小模型偶尔会陷入"错-重试-还是错"的循环,两三次失败就该换策略(换模型/降低 schema 复杂度),而不是无限重试

10. 终极方案:约束解码(constrained decoding)

前面都是"生成后补救"。存在一种釜底抽薪的手段:在采样阶段就屏蔽非法 token。

回忆第 4 章:模型每步在词表上产出一个概率分布,然后采样选出一个。约束解码的做法:在采样前,把所有不符合 schema 的候选 token 的概率直接置零——比如已经输出了 {"sentiment": "负,下一步只允许 面 这一个 token,其他全部禁止。

效果是100% 语法+结构合规(代价是牺牲一点内容自由度,且对解码速度有少量影响)。工程接入方式:

层级 方案
API 层 OpenAI 的 response_format={"type":"json_schema", strict:true},严格模式
框架层 Outlines / xgrammar / llama.cpp 的 grammar 模式(本地模型可用)
Ollama format 参数可传 JSON Schema(原生 API)
# 原生 Ollama API 传入 schema(OpenAI 兼容层之外的方式)
import ollama

schema = Sentiment.model_json_schema()    # Pydantic 直接生成 JSON Schema
resp = ollama.chat(
    model="qwen3:4b",
    messages=[{"role": "user", "content": "分析:这个产品用起来太糟心了"}],
    format=schema,                          # 约束解码:输出必合法
)

注意分工:约束解码保证结构,Pydantic 校验保证语义(枚举、范围、业务规则)。生产中两者叠加,再加校验-重试兜底,就是完整方案。

11. Schema 设计的实战建议

同样的约束目标,schema 写法不同,稳定性差异巨大:

做法 建议
字段越少越好 每个字段都是模型的一次"发挥机会",能删则删
用 Literal 锁枚举 开放字符串是自由发挥的重灾区
数值给范围 Field(ge=0, le=1) 比"0到1"的口头约定硬一万倍
可选字段给默认值 减少模型"必须编一个值"的压力
字段描述写进 schema Field(description=...) 会进入提示,模型看得见
拆大 schema 需要十几个字段时,拆成多次调用各取所需

最后一条是架构级经验:别让一次调用输出整个系统需要的所有数据。多个小而清晰的 schema 串联(分析→分类→生成),比一个巨型 schema 一次到位稳定得多——这也为第 8 章的 Agent 多步执行埋下伏笔。

本章小结

会话与上下文管理:

  • 会话 = 客户端维护的 messages 数组;第 N 轮输入包含全部历史,成本随轮次线性上升
  • token 计量:事前估算(字符比例法)+ 事后精确(usage 真值校准)
  • 三种裁剪策略:截断(零成本有损)、摘要压缩(主流选择)、检索式(第 15 章的长期记忆)
  • 裁剪要切在轮次边界上,否则后续 tool 调用对会被截断报错
  • 缓存命中的黄金法则:静态在前动态在后;时间戳放 user 消息尾部;system 保持字节级稳定

结构化输出:

  • 自由文本对程序是雷区;可靠输出 = 约束 + 校验 + 重试的三层组合
  • JSON 模式只保证语法合法;Pydantic 的 Literal/Field 才锁得住语义
  • 校验-重试循环:把 ValidationError 喂回给模型是最有效的修正提示
  • 约束解码在采样阶段屏蔽非法 token(第 4 章分布的改写),结构 100% 合规
  • Schema 设计原则:少字段、锁枚举、给范围、拆大 schema

到这里,"第二章 怎么用好它"正式收官。你已经掌握:调用模型与组织输入(第 5 章)、经营会话与锁定输出(本章)。万事俱备,只欠东风——让模型动起手来。

下一篇:07 · Tool Calling:协议与封装