《AI Agent 开发入门》7.2 会话状态对象

history 焊在 Agent 实例里,一个 Agent 只服务一段对话,也无法把对话存下来或跨进程搬走。本节引入 Session 状态对象,持有 messages、session_id、metadata、created_at,提供 append/snapshot/restore,把 history 剥出 Agent。用假模型跑两轮对话,验证 history 累积与 session_id 稳定。

7.2 会话状态对象

3.3 把 history 存进了 Agent 实例,多轮对话第一次跑通。当时那是个进步,但放到真实服务里,它就露馅了:一个 Agent 实例只承载一段对话的 history,要同时服务一百个用户就得造一百个 Agent;进程一重启,所有对话全部清零;更麻烦的是,history 和「这个 Agent 是谁」的配置混在一个对象里,想单独把对话存下来或搬走都无从下手。问题的根子是状态与配置被焊死在一起。

本节把 minagent 推进到「状态独立成对象」:新增 minagent/session.py 定义 Session(持有 messages / session_id / metadata / created_at,提供 append / snapshot / restore),并把 history 从 Agent 里剥出去——Agent 退回无状态的配置载体,状态全部交给 Session。

7.2.1 history 焊在 Agent 里的三个麻烦

把 3.3 的 Agent 想象成一个真实后端服务的构件,三个麻烦会依次冒出来:

麻烦表现根因
无法并发服务一个 Agent 只有一份 history状态与实例一对一绑定
无法持久化进程重启,对话全丢状态只活在内存里,无序列化出口
无法迁移想把会话挪到另一台机器做不到状态没有独立身份,取不出来

这三个麻烦指向同一个动作:把「状态」从 Agent 里赶出去。赶出去之后,Agent 只剩配置(model / registry / system / max_steps),变成彻底无状态的东西——同一个 Agent 实例可以轮流服务任意多个会话;而状态装进一个新对象,它有独立的身份、能序列化、能跨进程搬。

这个「无状态实例 + 有状态数据」的分法,是所有 Web 服务的通用套路:Controller 是无状态的,Session 存状态;Connection 是无状态的,Transaction 存状态。Agent 运行时不过是把这个老套路再走一遍。

7.2.2 Session 的四个字段

Session 是一个 dataclass,四个字段各司其职:

# minagent/session.py
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any

from .types import Message


@dataclass
class Session:
    session_id: str = field(default_factory=lambda: uuid.uuid4().hex[:12])
    messages: list[Message] = field(default_factory=list)
    metadata: dict[str, Any] = field(default_factory=dict)
    created_at: str = field(
        default_factory=lambda: datetime.now(timezone.utc).isoformat()
    )
字段类型作用
session_idstr会话的稳定身份,跨轮次、跨进程不变
messageslist[Message]消息历史,多轮对话的全部内容
metadatadict[str, Any]挂载业务信息:用户 id、租户、标签
created_atstr创建时刻(ISO 8601),用于排序与过期清理

两个 default_factory 值得停一下。session_id 用 uuid4().hex[:12]——取 12 位十六进制(48 位随机),碰撞概率低到可以忽略,长度又短到能塞进日志行。created_at 用 datetime.now(timezone.utc).isoformat()——带时区的 ISO 字符串,而不是裸 datetime 对象。原因在 7.3 会揭晓:这个对象要序列化成 JSON,而 datetime 默认不可 JSON 化;存成字符串,序列化零成本,还天然可读、可排序。

metadata 是刻意留的口子。核心字段只有 messages,但真实业务总想挂点别的——「这个会话属于哪个用户」「是不是 VIP」「上一轮用了什么模型」。把这些塞进 metadata,核心结构就永远干净,业务信息也不会污染消息历史。

7.2.3 append / snapshot / restore

Session 的接口只有三个动作,对应状态的一生:

    def append(self, msg: Message) -> None:
        self.messages.append(msg)

    def snapshot(self) -> dict[str, Any]:
        return {
            "session_id": self.session_id,
            "created_at": self.created_at,
            "metadata": dict(self.metadata),
            "messages": [message_to_dict(m) for m in self.messages],
        }

    @classmethod
    def restore(cls, data: dict[str, Any]) -> "Session":
        return cls(
            session_id=data["session_id"],
            created_at=data["created_at"],
            metadata=dict(data.get("metadata", {})),
            messages=[message_from_dict(m) for m in data.get("messages", [])],
        )
  • append:追加一条消息,最简单的动作。
  • snapshot:把整个会话压成一个纯 dict——注意 messages 里的 Message / ToolCall 是 dataclass,不能直接 json.dumps,得先转成 dict。这一步是 7.3 存盘的地基。
  • restore:snapshot 的逆运算,从 dict 还原出 Session。它是 classmethod,因为还原时还没有实例。

消息的 dict 互转由两个自由函数承担,Message 里的嵌套 ToolCall 要递归处理:

def message_to_dict(msg: Message) -> dict[str, Any]:
    return {
        "role": msg.role,
        "content": msg.content,
        "tool_calls": [
            {"id": tc.id, "name": tc.name, "arguments": tc.arguments}
            for tc in msg.tool_calls
        ],
        "tool_call_id": msg.tool_call_id,
    }


def message_from_dict(data: dict[str, Any]) -> Message:
    return Message(
        role=data["role"],
        content=data.get("content", ""),
        tool_calls=[
            ToolCall(id=tc["id"], name=tc["name"], arguments=tc["arguments"])
            for tc in data.get("tool_calls", [])
        ],
        tool_call_id=data.get("tool_call_id"),
    )

为什么不用 dataclasses.asdict 一步到位?因为它会深度递归,把 arguments 里的一切也照搬,遇到不可 JSON 化的值会直接炸;而且它输出的字段名与 Message 字段强耦合,将来给消息加一个内部字段(比如 timestamp)会立刻漏进存盘格式。手写这两个函数虽然啰嗦,但存盘格式与内存结构解耦——这是持久化数据的通用纪律:别让磁盘上的格式跟着内存里的类字段走。

7.2.4 Agent 变无状态:run_turn(session, …)

状态搬进 Session 后,Agent 的 history 字段删掉,run() 换成接收 Session 的 run_turn():

# minagent/agent.py
class Agent:
    def __init__(self, model, registry, system: str = "", max_steps: int = 8) -> None:
        self.model = model
        self.registry = registry
        self.system = system
        self.max_steps = max_steps
        # 注意:不再有 self.history

    def run_turn(self, session: Session, user_input: str) -> RunResult:
        messages = list(session.messages)
        if self.system and not messages:
            messages.append(Message(role="system", content=self.system))
        messages.append(Message(role="user", content=user_input))

        steps: list[Step] = []
        for i in range(self.max_steps):
            resp = self.model.complete(messages, self.registry.schemas())
            msg = resp.message
            messages.append(msg)
            if not msg.tool_calls:
                steps.append(Step(index=i, kind="final", detail=msg.content))
                session.messages = messages
                return RunResult(messages=messages, steps=steps, final=msg.content)
            for tc in msg.tool_calls:
                result = self.registry.call(tc)
                messages.append(Message(role="tool", content=result, tool_call_id=tc.id))
                steps.append(Step(index=i, kind="tool_call", detail=f"{tc.name} -> {result}"))
        steps.append(Step(index=self.max_steps, kind="error", detail="达到 max_steps 仍未收敛"))
        session.messages = messages
        return RunResult(messages=messages, steps=steps, final="")

对比 3.3 的 run(),改动只有三处,但性质全变了:

位置3.3本节
读历史self.historysession.messages
写历史self.history = messagessession.messages = messages
身份实例自带状态实例无状态,状态随 session 传入传出

system 的注入条件依旧是 if not messages——但现在判的是这个会话的历史是否为空,而不是 Agent 的。这意味着:新会话第一次进来会注入 system,同一会话第二轮则不会。会话之间彼此隔离,各注入各的。

7.2.5 跑一遍:两轮对话与两个会话

用一个假模型服务两个会话:会话 A 跑两轮(第一轮走工具、第二轮直接答),会话 B 全新开始:

agent = Agent(build_model(), build_registry(), system="你是一个计算助手。")

session_a = Session(metadata={"user": "alice"})
sid_a = session_a.session_id

r1 = agent.run_turn(session_a, "先算 2+3,再把结果乘 4")
print("A 第 1 轮:", r1.final, "| history:", len(session_a.messages))
r2 = agent.run_turn(session_a, "再加 10 是多少")
print("A 第 2 轮:", r2.final, "| history:", len(session_a.messages))

session_b = Session(metadata={"user": "bob"})
r3 = agent.run_turn(session_b, "你好")
print("B 第 1 轮:", r3.final, "| history:", len(session_b.messages))

本机实测(Python 3.14.6):

A 第 1 轮: 第一轮:结果是 20。 | history: 7
A 第 2 轮: 第二轮:在 20 基础上再加 10 是 30。 | history: 9
B 第 1 轮: 另一个会话:你好,我是新会话。 | history: 3
A 的 session_id 稳定: True | created_at 稳定: True
A 与 B 的 session_id 不同: True
B 的 history 3 条(新会话:system+user+assistant): 3

三个数字印证了三件事:

  • A 从 7 涨到 9:第二轮只新增 user 与 assistant 两条,没有重复注入 system——history 在 Session 上正常累积。
  • session_id 与 created_at 全程不变:同一个 Session 对象跨两轮保持身份稳定。这两条断言,正是「会话」这个概念成立的定义。
  • B 只有 3 条(system + user + assistant):全新会话从零开始,且注入了自己的 system。同一个 Agent 实例先后服务了 A、B 两个会话——无状态实例的价值就在这里。

snapshot / restore 的往返也一并验证:

snap = session_a.snapshot()
restored = Session.restore(json.loads(json.dumps(snap, ensure_ascii=False)))
print("恢复后 session_id 相同:", restored.session_id == session_a.session_id)
print("恢复后消息逐条相等:", [m.content for m in restored.messages]
      == [m.content for m in session_a.messages])
恢复后 session_id 相同: True
恢复后消息逐条相等: True
恢复后工具调用相等: True

json.dumps 再 json.loads 绕一圈,是为了模拟「真的过了一次磁盘」——如果 snapshot 的产物里有 datetime 这类不可序列化的值,这一步会立刻报错。往返后逐条相等,说明序列化是无损的,7.3 存盘才有底气。

7.2.6 为什么要把状态赶出 Agent

一句话概括这个分离的收益:Agent 变成纯函数式的配置,Session 变成可搬运的数据。收益拆开看有三条:

  • 可复用:一个无状态 Agent 实例可以并发服务任意多个 Session。并发模型从「一个用户一个 Agent」变成「一个 Agent 管所有用户」,内存与初始化成本骤降。
  • 可持久化:Session 能 snapshot 成纯 JSON,落盘、进 Redis、发到消息队列都行——7.3 正是靠这一点做检查点。
  • 可测试:测试时直接构造一个带指定 messages 的 Session 塞进去,不必去摸 Agent 的内部状态。状态是显式参数,测试就变成纯粹的数据驱动。

反过来,如果状态继续焊在 Agent 里,这三条一条也做不到。所以「无状态实例 + 有状态数据」不是洁癖,而是服务化的前提。第 17 章把 minagent 包成 HTTP 服务时,每个请求带着一个 session_id 进来、取出对应 Session、跑一轮、再存回去——这个流程能成立,全靠本节这次拆分。

7.2.7 真实模型下会怎样

假模型的 session_id 是本地 uuid4 生成的,真实服务里它通常由入口层(HTTP 网关、前端)分配并随请求带上,因为同一个会话的多次请求可能落到不同进程,必须有一个全局认得的标识把它们串起来。

另一个真实差异在 messages 的规模。假模型的 history 只有几条,snapshot 出来的 JSON 不过几百字节;真实多轮对话里,messages 会包含长系统提示、几十轮问答、每次工具调用的完整往返,snapshot 可能达到几百 KB 甚至 MB。这意味着 snapshot 不能太频繁(7.3 会讲频率取舍),也不能每次都全量写——大会话的持久化要考虑增量或分片。

还有一点:真实场景里 metadata 往往会承载并发控制信息,比如「这个会话正在被哪个进程处理」的锁标记。假模型的单线程演示看不出竞争,但多个请求同时落到同一个 session_id 时,messages 的读改写会互相覆盖。这是第 13、14 章并发话题的伏笔,本节先记下这个坑。

到这里,会话状态已经是一个能 snapshot 成 JSON 的独立对象。但「能序列化」离「崩溃后能续跑」还差一步——snapshot 只是一次内存快照,真正要做的,是在运行途中把它写进磁盘,并在重启后从断点接上。下一节 7.3 检查点与快照 就来补这一步。想回看事件流是怎么订阅的,翻 7.1 流式事件模型 。

阅读导航:上一节:7.1 流式事件模型 · 下一节:7.3 检查点与快照 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「agent」更多文章

  1. 《AI Agent 高级编程》目录
  2. 《AI Agent 高级编程》11.3 全景回顾:从零到框架的完整路径与展望
  3. 《AI Agent 高级编程》11.2 真实 API 冒烟:假模型 vs 真模型量化差距