Skip to content

第 9 章 会话要活下去 ​

本章代码 code/ch09/store.py + code/ch09/main.py · 读完约 16 分钟

9.0 上一章留下的问题 ​

第 8 章的 Buddy 已经很像样了:会列计划、会压缩上下文、会在合适的时候注入时间。

但它的一切都在内存里。Ctrl+C 之后,会话消失,任务清单消失,你昨天跟它说过的"周报固定分三段"也消失了。

这一章解决这件事。要解决它,得先把一个反直觉的事实讲清楚。

AgentState 是这段会话的可序列化存档:消息、摘要、回复进度、权限、工具和任务都在里面;模型 API 不记得上一轮,只有把它存下来、下次喂回去,会话才能继续。

9.1 Agent 本身不保存记忆 ​

"我的助手记得我们聊过什么"——更准确的说法是:记忆不在 Agent 对象里,而在 AgentState 里;Agent 每次都可以重新构造,只要把同一份 state 喂给它。

看 Agent 的构造签名,其中有一个 state 参数:

python
Agent(name=..., system_prompt=..., model=..., toolkit=...,
      state: AgentState | None = None, ...)

不传,框架造一个空的;传了,它就直接用你这份。Agent 对象只保存配置(名称、模型、工具、各种 config)和一个指向 state 的引用,不另外维护一份"对话记忆"。

这解释了三件事:

  1. 两个浏览器标签为什么能各聊各的。 每个标签的请求带不同的 session_id;服务端按 id 读到两份不同的 AgentState,分别构造两个 Agent。两份 state 里的 context 不共享,消息就不会串。反过来,两个标签用同一个 session_id,就会看到同一段对话。
  2. 恢复历史会话为什么不需要特殊 API。 重启后,store.load() 把 JSON 读回成 AgentState,再 Agent(..., state=state);下一轮 reply() 直接在这份 context 后面继续。框架没有隐藏的"会话内存",所以也不需要额外的恢复接口。
  3. 模型、工具、中间件为什么可以换。 历史消息在 state 里,不在 model 或 toolkit 对象里。保留 state,换一个 model= 或 toolkit=,下一轮仍然带着之前的对话。注意改 system_prompt 会改变模型行为,但不会让历史消失。

AgentState 有八个字段。判断一个东西要不要放进 state,只需问一句:进程重启后,下一轮还要不要用它?

字段内容丢了会怎样
session_id会话标识会话无法被找回
summary压缩后的摘要长任务的来龙去脉断了
context消息数组全部对话历史消失
reply_context当前回复的进度(reply_id、cur_iter、结构化输出要求)断点续跑失效
permission_context权限模式与规则用户刚点的"始终允许"忘了
tool_context读文件缓存、已激活的工具组工具组要重新激活
tasks_context任务清单计划全丢
middle_context你自己写的中间件塞进去的状态自定义逻辑失忆

9.2 三个键定一条会话 ​

单靠 session_id 不够。同一个应用里会有多个用户、多个智能体,它们的 session_id 可能撞车(尤其是用短 id 的时候)。所以定位一条会话需要三个键:

text
user_id  →  谁在聊
agent_id →  跟哪个智能体聊
session_id → 聊的哪一段

对应到文件系统,就是三层目录:

text
.sessions/<user_id>/<agent_id>/<session_id>.json       完整状态
.sessions/<user_id>/<agent_id>/<session_id>.meta.json  列表用的元信息

把元信息单独存一份,是个很实际的优化:会话列表页面只需要"标题、更新时间、消息条数",不需要把每条会话的完整 JSON 都读出来解析一遍。二十条会话可能只有几百 KB,但一千条就是几十 MB。

session_id 从哪来?AgentState 会自己生成一个 32 位的十六进制串:

text
b075a2a2a1934f46a76bc5dff28db63c

用它的好处是够随机。也可以自己指定——如果业务上需要用"群聊 + 日期"这种可读 id,直接赋值即可:

python
state = AgentState()
state.session_id = "project-weekly-2026-10"

9.3 存与取:两行代码 ​

状态落盘与恢复

图 9-1 状态落盘与恢复——中间那个文件就是全部记忆

AgentState 是 pydantic 模型,它的八个字段也都是 pydantic 模型。这意味着序列化不需要任何手写逻辑:

python
# 存
state.model_dump_json(indent=2)

# 取
AgentState.model_validate_json(text)

model_dump_json() 会递归展开所有嵌套对象——Msg、ContentBlock、Task、PermissionRule 全在里面。存出来的文件长这样(真实输出,截取前半段):

json
{
  "session_id": "8afdedb7b46d431dbe67be1dd71b74ef",
  "summary": "之前聊过周报格式",
  "context": [
    {
      "name": "user",
      "content": [
        {
          "type": "text",
          "text": "你好",
          "id": "37fcf37322e545f88240f2cb84583fa3",
          "created_at": "2026-10-22T10:42:13.977472",
          "finished_at": null
        }
      ],
      "role": "user",
      "id": "46770ac7339546598c6a6f95e154491e",
      "created_at": "2026-10-22T10:42:13.977467",
      "usage": null,
      "finished_at": null,
      "finished_reason": null
    }
  ],
  "reply_context": { "reply_id": "...", "cur_iter": 0, "structured_schema": null },
  "permission_context": { "mode": "default", "allow_rules": {}, "deny_rules": {}, "ask_rules": {} },
  "tool_context": { "max_cache_files": 100, "max_cache_bytes": 25000, "read_file_cache": [], "activated_groups": [] },
  "tasks_context": { "tasks": [] },
  "middle_context": {}
}

一个小样本——一轮对话加两条任务——大约 1.9 KB。这个数字说明:文件存储对教学和中小规模应用完全够用。真到了几千并发再换数据库,接口形状不用变。

往返验证(code/ch09/verify_roundtrip.py 的真实输出):

text
saved files: ['02c1a830d3034e728ab060841f4aca1b.json', '02c1a830d3034e728ab060841f4aca1b.meta.json']
restored msg: 1 tasks: ['写进度', '写问题']
structured_schema after load: None
list: [{'session_id': '02c1a830…', 'title': '记住:我的周报固定分三段',
        'updated_at': '2026-10-22 12:10:45', 'message_count': 1, 'task_count': 2}]
size: 1895

消息回来了,任务回来了,标题自动取到了第一条用户消息。看起来一切都对。

但 structured_schema 那一行是 store.load() 里手动清掉的,不是序列化自己变的。 这是本章最值得写下来的一条。

9.4 恢复时必须处理的三个残留 ​

序列化是无损的,可惜"无损"不等于"正确"。有些状态在上一轮里是对的,在新的一轮里是错的。

9.4.1 残留一:reply_context.structured_schema ​

如果你上一轮用结构化输出要了一份 JSON,structured_schema 就会留在 state 里。恢复之后,Agent 在开始新一轮回复时会做这件事(源码 _agent.py 约 1109 行):

python
if self.state.reply_context.structured_schema:
    await self.toolkit.add_tool(
        _GenerateStructuredOutput(schema=self.state.reply_context.structured_schema),
    )

后果是:你的用户只是想问一句"今天几号",模型却被迫按上次那个 schema 输出 JSON;如果它不输出,框架还会追加一次收尾调用逼它输出。

正确做法是加载后立刻清空:

python
state.reply_context.structured_schema = None
state.reply_context.structured_output = None

9.4.2 残留二:reply_context.cur_iter ​

cur_iter 是"当前这一轮已经迭代到第几步"。正常结束时它会被重置;如果上一轮是被强制中断的(进程被杀、CancelledError),它可能停在一个非零值。

恢复一个 cur_iter != 0 的状态,等于让 Agent 以为"这一轮还没结束",它会跳过一些只在轮次开始时做的事——包括上下文压缩检查和时间注入。症状很隐蔽:压缩永远不触发、时间一直是旧的。

保守做法:恢复时把它归零。真正的断点续跑需求(第 13 章的中断)另有一套机制,不靠这个字段。

9.4.3 残留三:tool_context.activated_groups ​

第 6 章讲过工具组——模型用 reset_tools 按需激活。激活列表存在 tool_context 里。

这个字段应该保留,不要清。清掉的后果是:模型记得"我刚才激活过 reporting 组",但实际上工具已经不在 toolkit 里了,下一次调用直接报"未知工具"。它会更困惑,然后重新激活一遍——多花两个来回。

也就是说,这三个残留要区别对待:

字段恢复时理由
structured_schema / structured_output清空上一轮的要求不该延续到新一轮
cur_iter归零否则跳过轮次开始时的检查
activated_groups保留工具组的状态必须和 toolkit 一致

9.5 会话列表 ​

第 12 章的前端要画一个会话列表,这里顺手把数据结构做出来。

list_sessions() 读的是 .meta.json,按更新时间倒序:

python
def list_sessions(self, user_id, agent_id) -> list[dict]:
    directory = self._dir(user_id, agent_id)
    sessions = []
    for name in os.listdir(directory):
        if name.endswith(".meta.json"):
            sessions.append(json.load(open(os.path.join(directory, name), encoding="utf-8")))
    sessions.sort(key=lambda m: m["updated_at"], reverse=True)
    return sessions

元信息有六个字段:

json
{
  "session_id": "8afdedb7b46d431dbe67be1dd71b74ef",
  "title": "记住:我的周报固定分三段",
  "updated_at": "2026-10-22 10:43:06",
  "message_count": 1,
  "task_count": 2,
  "summary_len": 0
}

标题的处理值得说一下。让模型生成标题是最好的,但要多一次调用;教学版用了一个更省的办法——取第一条用户消息的前 24 个字:

python
@staticmethod
def _guess_title(state: AgentState, limit: int = 24) -> str:
    for msg in state.context:
        if getattr(msg, "role", "") == "user":
            text = (msg.get_text_content() or "").strip().replace("\n", " ")
            if text:
                return text[:limit]
    return "新会话"

效果出乎意料地好,因为用户的第一句话通常就是任务描述。等到第 14 章我们有了用量统计,可以再加一条规则:对话超过 6 轮时,用一次廉价调用重写标题。

9.6 完整代码走读 ​

code/ch09/main.py 的主线只有四步。

第一步,看有没有指定会话。

python
session_id = sys.argv[1] if len(sys.argv) > 1 else None
state = store.load(USER_ID, AGENT_ID, session_id) if session_id else None

第二步,造 Agent。 注意 state=state,None 时框架自己造。

python
agent = await build_agent(workspace, state)

第三步,聊,每轮之后存一次。

python
await chat_once(agent, "记住:我的周报固定分三段,进度、问题、下周计划。")
store.save(USER_ID, AGENT_ID, agent.state)

每轮存一次而不是退出时存一次,是因为"退出时"这个时机不可靠——进程可能被 kill,异常可能直接终止。在状态发生变化的地方立刻落盘,是这章最实用的一条工程习惯。

第四步,打印会话列表。

text
----------------------------------------------------------------------
会话列表:
  8afdedb7…  2026-10-22 10:43:06  消息 2  任务 3  记住:我的周报固定分三段
----------------------------------------------------------------------

想验证恢复,就把这个 id 传回去:

bash
python main.py 8afdedb7b46d431dbe67be1dd71b74ef

启动时会看到:

text
======================================================================
恢复会话 8afdedb7b46d431dbe67be1dd71b74ef
  历史消息 2 条 摘要 0 字
  任务 3 条
======================================================================

然后问一句"我的周报分几段",它答得出来——因为那段话就在 context 里。

9.7 三个常见错误 ​

错误 1:用 json.dump(state) 代替 model_dump_json()

python
json.dump(agent.state, f)      # TypeError: Object of type AgentState is not JSON serializable

AgentState 是 pydantic 模型,不是 dataclass。要用它自己的 model_dump_json();想先转成 dict 再处理,用 model_dump(mode="json")。

错误 2:恢复后忘了清 structured_schema

症状是"新会话的第一句回复变成了一段 JSON"。第一次遇到会怀疑模型坏了,其实是上一轮的要求被带过来了。加载后立刻清空,别指望它自己归零。

错误 3:把整个 Agent 对象 pickle 下来

python
pickle.dump(agent, f)          # 大概率失败

模型对象里握着 HTTP 客户端,工具对象里握着文件句柄,这些都不可序列化。要存的永远只是 state;Agent 是按需重新构造的。存状态,不存对象——这条不管用哪个框架都成立。

9.8 小结 ​

  • Agent 是无状态的,记忆全在 AgentState 里;恢复会话就是"读文件 + 造 Agent + 传 state"
  • 三个键定位一条会话:user_id / agent_id / session_id
  • model_dump_json() / model_validate_json() 两行完成往返,不需要手写序列化
  • 恢复时要清 structured_schema 和 cur_iter,但必须保留 activated_groups
  • 在状态变化处立刻落盘,别等进程退出
  • 元信息单独存一份,会话列表不必读全量状态

到这里,Buddy 能干活、守边界、会问人、做长任务、还记得上次聊到哪。剩下的事情在"框架内部":怎么在关键节点插手,怎么让它必须按你要的格式回答。

下一章要解决的问题:工具调用前后想加日志、想脱敏、想改参数,除了改工具本身还有别的办法吗?怎么强制模型输出一段符合 schema 的 JSON?

教程正文采用 CC BY-NC-SA 4.0 许可,code/ 下示例代码可自由使用