会话持久化与恢复

多轮对话 + 工具调用会让 messages 越来越长:退出程序就全丢,重来一遍既费钱又费时间。本章把 messages 原样存档成 JSON 文件,支持按会话 id 恢复、列出与切换多个会话,让程序重启后还能"接着上次聊"。

存储格式:整条 messages 原样存

工具调用产生的 assistant 消息里带 tool_calls、tool 消息里带 tool_call_id,它们必须原样保留才能继续对话。好消息是这些结构天然可 JSON 序列化,直接整条存档即可:

# agent_demo/session.py —— 第 12 章:会话存档模块
import json
import os
import time

SESSIONS_DIR = os.path.join(os.path.dirname(__file__), "sessions")
os.makedirs(SESSIONS_DIR, exist_ok=True)

def _path(sid: str):
    return os.path.join(SESSIONS_DIR, f"{sid}.json")

def save_session(sid: str, messages: list) -> str:
    """把整段 messages 存为 {sid}.json,返回存档时间。"""
    ts = time.strftime("%Y-%m-%d %H:%M:%S")
    data = {"sid": sid, "updated_at": ts, "messages": messages}
    with open(_path(sid), "w", encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False, indent=2)   # 存成 UTF-8,中文可读
    return ts

def load_session(sid: str) -> dict:
    """按 id 读回 {sid, updated_at, messages}。"""
    with open(_path(sid), encoding="utf-8") as f:
        return json.load(f)

def list_sessions() -> list:
    """列出全部会话:[(sid, 更新时间, 消息数)],新的在前。"""
    out = []
    for fn in os.listdir(SESSIONS_DIR):
        if fn.endswith(".json"):
            s = load_session(fn[:-5])
            out.append((s["sid"], s["updated_at"], len(s["messages"])))
    return sorted(out, key=lambda x: (x[1], x[0]), reverse=True)   # 时间新、id 大者在前

def delete_session(sid: str) -> None:
    os.remove(_path(sid))

存档内容长什么样

一次真实的"记待办"过程(含工具消息)存进文件后大致如下,注意 tool_calls 的 id 与 tool 消息的 tool_call_id 一一对应:

{
  "sid": "demo-001",
  "updated_at": "2026-01-08 10:00:12",
  "messages": [
    {"role": "system", "content": "你是待办助手"},
    {"role": "user", "content": "帮我记下:明天上午 9 点开会"},
    {"role": "assistant", "content": null,
     "tool_calls": [{"id": "call_ab12", "type": "function",
                     "function": {"name": "todo_add",
                                  "arguments": "{\"task\": \"明天上午 9 点开会\"}"}}]},
    {"role": "tool", "tool_call_id": "call_ab12", "content": "已添加待办 #3"},
    {"role": "assistant", "content": "好的,已记下:明天上午 9 点开会。"}
  ]
}

演示:保存、恢复、列表与切换

下面的演示不需要模型也能跑——真实历史可以在第 8 章 run_agent 每步之后把 messages 存档一份,也可以像这里一样手工构造:

# demo_session.py —— 第 12 章(不联网可跑)
from session import save_session, load_session, list_sessions

def fake_history():
    """模拟一段含工具调用的历史(真实项目来自 run_agent 的 messages)。"""
    return [
        {"role": "system", "content": "你是待办助手"},
        {"role": "user", "content": "帮我记下:明天上午 9 点开会"},
        {"role": "assistant", "content": None,
         "tool_calls": [{"id": "call_ab12", "type": "function",
                         "function": {"name": "todo_add",
                                      "arguments": '{"task": "明天上午 9 点开会"}'}}]},
        {"role": "tool", "tool_call_id": "call_ab12", "content": "已添加待办 #3"},
        {"role": "assistant", "content": "好的,已记下:明天上午 9 点开会。"},
    ]

if __name__ == "__main__":
    save_session("demo-001", fake_history())
    save_session("demo-002", fake_history()[:2])      # 另一个较短的会话
    print(list_sessions())
    # 输出:[('demo-002', '2026-01-08 10:01:00', 2), ('demo-001', '2026-01-08 10:00:59', 5)]
    s = load_session("demo-001")
    print(f"恢复 {s['sid']}:{len(s['messages'])} 条消息,最后一句:"
          f"{s['messages'][-1]['content']}")
    # 输出:恢复 demo-001:5 条消息,最后一句:好的,已记下:明天上午 9 点开会。

新进程续聊:恢复后直接接上

重启程序后想接着上次聊,读出 messages 丢给对话循环即可,模型完全不知道"中间重启过":

# 接续对话(需要 .env:LLM_API_KEY/LLM_BASE_URL,见第 2 章)
from llm_client import LLMClient       # 沿用第 2 章客户端
from session import load_session, save_session

llm = LLMClient()
messages = load_session("demo-001")["messages"]     # 整段历史(含 tool 消息)原样拿回
messages.append({"role": "user", "content": "顺便记下周四下午 2 点评审"})
reply = llm.chat_text(messages)                     # 接着聊,模型能看到此前全部上下文
messages.append({"role": "assistant", "content": reply})
save_session("demo-001", messages)                  # 聊完再存回去

若要恢复的是"带工具调用"的对话,直接把读回的 messages 交给第 8 章 run_agent 继续即可——它的输入输出都是 messages,历史里 assistant 的 tool_calls 与 tool 回填消息已一一对应,无需特殊处理;每轮结束 save_session 一次,或把存档时机放进 run_agent 内部每步之后,就能实现断点续聊。

多会话切换则只需在启动时给用户一个菜单:

# 前置:from session import list_sessions, load_session

def pick_session():
    """启动菜单:列出旧会话让用户选,或新建一个。"""
    sessions = list_sessions()
    for i, (sid, ts, n) in enumerate(sessions, 1):
        print(f"{i}. {sid}({n} 条消息,{ts})")
    print("n. 新建会话")
    choice = input("选择:").strip()
    if choice == "n":
        return [], input("新会话 id:").strip()
    return load_session(sessions[int(choice) - 1][0])["messages"], \
           sessions[int(choice) - 1][0]

注意三点

第一,JSON 只能存可序列化内容,messages 里不要塞自定义对象或二进制;第二,sessions 目录建议写进 .gitignore,别把对话内容提交进仓库;第三,文件会随对话无限增长,正式产品可加"按条数/日期滚动存档、旧的落冷存储"策略。

小结:会话持久化就是"messages 原样存 JSON、按会话 id 恢复"两件事——save_session/load_session/list_sessions 三个函数即可支撑多会话列表与切换;含工具调用的历史因为结构天然可序列化,存下来重启后能无缝续聊。

笔记加载中…