Day05 MCP → Day06 · 保险顾问 Agent 后端(大白话版)

把代码翻译成人话,每一行都拆给你看 · 以你真实代码为准
大白话 · 逐行拆解 · 止于 5.5
Step 0 · 先看地图

0今天这一大段,到底在搭什么?

先别管代码。用一句话讲清楚你这两天在做的**一件事**,再一条路一条路把它走完。

大白话总纲:Day05 教你「怎么给 AI 装外挂(MCP)」;Day06 教你「把这个 AI 请进自己的项目,配上数据库记性,让它在网页上流式聊天、还能翻旧账(会话历史)」。你今天做到第 5.5 步 = 能翻旧账。

咱把「保险顾问 Agent」当成一家「接线员服务」来理解,每个角色各管一段:

📞
Agent(接线员)用 DeepSeek 大脑 + 人设,负责「听懂话并回话」
📒
checkpointer(记事本)按会话号 thread_id 把聊天记下来,下次接着聊
🖥️
Chat 接口网页和 Agent 之间的「传声筒」,字儿一个一个字传
🗂️
chat_thread(档案柜)管理「有哪几个会话」,5.5 用它翻聊天记录
先说清范围:讲义 Day06 到「生成和保存保险方案」才结束,但你 只做到 5.5「查会话历史」。之后的两节(第6节 查询工具、第7节 保存方案)你还没写,所以教程到 5.5 就收尾,只留个「下集预告」。

整个项目目录长这样,我们后面一步一个文件地过:

agent-service / app / 目录
app/
├── main.py                      开机总装:启动流程、注册接口
├── core/config.py               配置中心(数据库/模型/报警)
├── common/models.py             表的"祖爷爷" + 时间戳工具
├── infra/
│   ├── database.py              业务库的连接池
│   └── checkpointer.py          Agent 记事本(连接池)
├── agents/insurance_advisor_agent.py   接线员本体
└── modules/
    ├── chat_thread/             档案柜:会话管理 + 翻历史(5.5)
    └── chat/                    传声筒:SSE 流式对话
Step 1 · Day05

1MCP:怎么给 AI 装上「能干活的手」

来源:你仓库 notebook agent-service/notebooks/day05/11.mcp.ipynb

AI 光会「说」,不会「做」(查天气、查高铁票)。想让它做,就得给它工具MCP 就是一套统一规格的「工具插槽」:别人做好一个个「工具服务器」,你按规矩把 AI 插上去,它就会用了。

打个比方:MCP = 标准 USB 口。时间服务器、12306 服务器都是「U 盘」,get_tools() 是把 U 盘插上,create_agent(tools) 是告诉 AI「现在你有这些 U 盘可以用」。

你在 notebook 里是这么写的——一次插两个外挂:

11.mcp.ipynb
client = MultiServerMCPClient({
    "time-mcp": { "transport": "stdio", "args": ["-y", "time-mcp"], "command": "npx" },
    "12306-mcp": { "transport": "streamable_http", "url": "https://mcp.api-inference.modelscope.net/..." }
})
tools = await client.get_tools()

model = init_chat_model(model='deepseek-v4-flash',
    extra_body={'thinking': {'type': 'disabled'}})

agent = create_agent(model=model, tools=tools, system_prompt='你是我的AI智能伴侣')

response = await agent.ainvoke({
    'messages': [HumanMessage('帮我查下周四从郑州到北京的高铁票?')]
})
🔍 这 6 行代码,每行在干嘛
代码大白话解释
client = MultiServerMCPClient({...})造一个「外挂管理器」,把想接的外挂都登记进来。
"time-mcp": { "transport":"stdio", "command":"npx", "args":[...,"time-mcp"] }登记第一个外挂叫 time-mcp。stdio = 在你电脑本地跑一个程序;npx 是启动命令,-y 表示遇到确认自动按「是」。
"12306-mcp": { "transport":"streamable_http", "url":... }登记第二个外挂叫 12306-mcp。streamable_http = 走网络,直接访问这个远程地址上的服务,不用本地装东西。
tools = await client.get_tools()「进货」:把两个外挂里所有能用的工具,一次性取出来,存进 tools 列表。await = 这是异步操作,要等它干完再走下一步。
model = init_chat_model('deepseek-v4-flash', thinking disabled)造「大脑」= DeepSeek 模型。thinking disabled = 别把思考过程说出来,直接给结论。
agent = create_agent(model, tools, system_prompt)「组装」:把大脑 + 工具 + 人设台词打包成一个能自理的 Agent。
response = await agent.ainvoke({...HumanMessage('查高铁')})真的问一句。AI 收到问题,觉得需要查票就自动调用 12306 工具,最后把答案交回来。
对照你的真实项目:这段只是派头演练。你正式项目里 insurance_advisor_agent.py 写的 tools=[](空工具箱),说明 它还没接任何 MCP/工具。MCP 是「学会装手」,Day06 是「把 AI 请进正式项目」。
Step 2 · Day06 开工准备

2config.py:项目所有的「开关和钥匙」

文件:app/core/config.py

一个项目要跑起来,得知道「数据库在哪、用什么 AI、端口多少、日志多细」。这些从 .env 环境变量里读,统一放到全局的 settings 对象里,谁要用谁直接拿。

打个比方:config.py 就像家里的「总电闸表 + 门禁钥匙」——数据库地址是钥匙串上的两把钥匙,DeepSeek 的 key 是充电卡,端口是房间号。

关键点在这两个被「自动算出来」的数据库地址:

config.py · 两个数据库地址
class DatabaseSettings(EnvSettings):
    @computed_field
    @property
    def url(self) -> str:
        return self.build_url("postgresql+asyncpg")
    @computed_field
    @property
    def checkpoint_url(self) -> str:
        return self.build_url("postgresql")

settings = Settings()
🔍 每行在干嘛
代码大白话解释
@property + @computed_field把下面方法设成「虚拟属性」:一调用立刻现场算出结果,像读一个普通字段一样。
def url(self) -> str: return build_url("postgresql+asyncpg")算出业务库完整地址。加上 asyncpg = 用异步驱动读写(业务数据用这套,快)。
def checkpoint_url(self) -> str: return build_url("postgresql")算出记忆库完整地址,不带异步驱动(因为 Agent 的 checkpointer 用自己那套 psycopg 连接)。所以是两个地址。
settings = Settings()进程一启动就建好这个唯一的配置对象。之后任何文件 import settings 都拿到同一个,不重复读文件。
记牢:settings.db.url = 业务库;settings.db.checkpoint_url = 记忆库;settings.llm.chat_model / api_key = AI 大脑。后面每个文件都靠它们。
Step 3 · Day06 开工准备

3Base 祖类 + 连接池:数据库的「地基和自来水管」

文件:common/models.py + infra/database.py

A. 表的「祖爷爷」(common/models.py) 所有业务表都要是个「类」,这些类都从同一个老祖宗 Base 生出来,就能统一被数据库认识。再配两个「混入工具」,让每张表自动带创建/修改时间,不用每张表都写一遍。

common/models.py
class Base(DeclarativeBase):
    def __repr__(self): ...

class CreateAtMixin:
    created_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), server_default=func.now())

class UpdateAtMixin:
    updated_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True),
        server_default=func.now(), onupdate=func.now())
🔍 每行在干嘛
代码大白话解释
class Base(DeclarativeBase)定义「所有表的老祖宗」。别的表继承它,数据库就能认出它们是一伙的。
def __repr__(self)一个爱好:让你在控制台打印这个对象时好看好读,不打印一堆内存地址。
class CreateAtMixin / UpdateAtMixin两个「混入工具」,不是表,是零件。谁继承了就给谁装上「创建时间/修改时间」两个字段。
created_at: Mapped[datetime] = mapped_column(...)声明一列,类型是「带时区的日期时间」。
server_default=func.now()数据库自动填默认值 = 插入这条数据的那一刻,写当前时间。你不用手动给时间。
onupdate=func.now()每次更新这一行时,自动把「修改时间」改成现在。省得手写。

B. 连接池 + 依赖注入(infra/database.py) 数据库连接很贵,不能每个请求都新开一条。于是提前备好一「池子」连接,谁要就给谁一条。

infra/database.py
engine = create_async_engine(DATABASE_URL, echo=settings.db.echo,
    pool_size=5, max_overflow=10, pool_pre_ping=True)

AsyncSessionFactory = async_sessionmaker(bind=engine,
    autoflush=False, expire_on_commit=False)

async def get_session():
    async with AsyncSessionFactory() as session:
        yield session
🔍 每行在干嘛
代码大白话解释
create_async_engine(...)造一个「业务库连接池」备用。
echo=settings.db.echo要不要把执行的 SQL 打到日志里(调试用)。
pool_size=5, max_overflow=10池里平时常备 5 条连接;忙不过来时,最多临时加 10 条。
pool_pre_ping=True借出前先「ping」一下,确保不是坏连接,别借到半路断掉的。
async_sessionmaker(bind=engine)一个「会话工厂」——专门按需生产 Session(一次数据库操作所需的会话)的对象。
autoflush=False查询前别自动把还没提交的改动写进库,减少意外。
expire_on_commit=False提交后字段别「过期」,之后读它的值不用再回数据库捞一遍。
get_session() ... yield session依赖注入:每个 HTTP 请求自动进来拿一条会话,yield 交出去用,请求结束自动关闭归还池子。等于「用前借、用后还」。
一句话:连接池 = 游泳池(5 条泳道,忙时多开 10 条),get_session = 前台借泳道给你,游完自动还。
Step 4 · Day06 开工准备

4数据库里已经躺着哪些表?

文件:deploy/postgres/init/01-init-biz-schema.sql

数据要落地,得先有「表格」。这些表用 SQL 建好,还自带演示数据。跟今天相关的四张:

表名存的是什么对应哪一步装好了吗
users用户请求头 x-user-id 用来识别「你是谁」✅ 已建,含演示用户 demo(id=1)
chat_threads一个会话第 8 步「翻历史」靠它✅ 已建
products保险产品第9/下集「查产品」的底料(到 5.5 为止还不用)✅ 已建,含 12 款演示产品
insurance_plans / insurance_plan_items保险方案 + 明细下集「保存方案」要用的(你还没写代码)⚠️ 表在,代码无

产品的 category 只有 4 个分类(未来「候选产品查询」全靠它分类):

🩺
medical医疗险
💔
critical_illness重疾险
🚑
accident意外险
life寿险
今天只用得上前两行。你的目标是「对话能记住、能翻旧账」,主要靠 chat_threads 表和即将看到的 checkpointer 表。产品表和保险方案表是后面的戏。
Step 5 · Day06 §1

5checkpointer:帮 AI 记住「上次聊到哪」的记事本

文件:app/infra/checkpointer.py(对应讲义 1.1~1.5)

AI 每次对话天生「失忆」。怎么记住?想法是:用一个电话号码(thread_id 会话号)当抽屉号,把每次聊天的状态写进数据库这个「抽屉」。下次还拿同一个号,就把记录捞出来接着聊。

infra/checkpointer.py(完整)
checkpoint_pool = AsyncConnectionPool(
    conninfo=settings.db.checkpoint_url,
    min_size=1, max_size=5,
    kwargs={
        "autocommit": True,
        "prepare_threshold": 0,
        "row_factory": dict_row,
    },
    open=False,
)

async def init_checkpointer():
    await checkpoint_pool.open()
    await checkpoint_pool.wait()
    checkpointer = AsyncPostgresSaver(checkpoint_pool)
    await checkpointer.setup()
    return checkpointer

async def close_checkpointer():
    await checkpoint_pool.close()
🔍 每行在干嘛
代码大白话解释
AsyncConnectionPool(conninfo=checkpoint_url, ...)给「记事本」单独开个连接池,目的地是记忆库。因为它跟业务库用途不同,所以单独一摊。
"autocommit": True写完立刻生效保存,不用手动提交(记事本就是图个快)。
"prepare_threshold": 0不预编译 SQL,简单点直接跑。
"row_factory": dict_row查询结果以「字典」形式返回(一行 = 一个 dict),好读好用。
open=False先别急着连,等程序真正启动时再手动 open(),方便控制时机。
async def init_checkpointer()一个「初始化函数」,应用启动时调用它来把记事本准备好。
await checkpoint_pool.open()打开这个连接池(开始建立连接)。
await checkpoint_pool.wait()等它把所有连接就绪,别急着往下走。
checkpointer = AsyncPostgresSaver(checkpoint_pool)造出真正的「记事本管理器」,管着往这个数据库里存/取对话状态。
await checkpointer.setup()自动建好记事本要用的那些表(第一次运行建,之后跳过)。
return checkpointer把做好的记事本交出去,给后面造 Agent 用。
close_checkpointer()程序关停时调用,把连接池关掉、还资源,免得泄露。
一句话:thread_id = 抽屉钥匙。同一把钥匙找回同一段聊天;换钥匙 = 新会话。第 7 步的「边聊边记」和第 8 步的「翻旧账」都靠这个记事本。
Step 6 · Day06 §2

6初始化保险顾问 Agent:把「接线员」请出来

文件:app/agents/insurance_advisor_agent.py(对应讲义 2.1~2.3)

这个文件就干一件事:造出我们的保险顾问接线员。注意它跟第 1 步 MCP 长的像,唯一差别在 tools

insurance_advisor_agent.py(完整)
SYSTEM_PROMPT = """
你是"安心保"的智能保险顾问。
你需要使用专业、准确、容易理解的语言回答用户的保险问题。
当信息不足时,应先向用户追问,不要编造保险产品或保障内容。
"""

def init_agent(checkpointer: AsyncPostgresSaver):
    tools=[]   # ← 现在空的!MCP 讲的那个 get_tools() 没接进来

    model = init_chat_model(
        model=settings.llm.chat_model, api_key=settings.llm.api_key,
        extra_body={"thinking": {"type": "disabled"}})

    agent = create_agent(
        model=model, tools=tools,
        checkpointer=checkpointer,
        system_prompt=SYSTEM_PROMPT)
    return agent
🔍 每行在干嘛
代码大白话解释
SYSTEM_PROMPT = """..."""写死的一段「人设台词」:告诉 AI「你是安心保保险顾问,说话专业易懂,信息不够先追问,别瞎编」。AI 每答一句都会参考它。
def init_agent(checkpointer)一个「生产 Agent 的工厂函数」,收进第 5 步的记事本作为原材料。
tools=[]空工具箱。这就是区别:现在没装任何工具,所以接线员只会「动嘴」不会「动手」。
model = init_chat_model(settings.llm.chat_model, api_key)拿配置里的大脑(DeepSeek 模型)和钥匙(API key),造出 AI 大脑。
extra_body={"thinking":{"type":"disabled"}}关掉「思维链」:让 AI 别把推理过程说出来,直接给结果(更快更像真人)。
agent = create_agent(model, tools, checkpointer, system_prompt)「总装」:大脑 + 工具箱 + 记事本 + 人设,拼成一个完整 Agent。
return agent把拼好的接线员交出去。
对比 MCP 那课:那里 tools=tools(会查票会看时间),这里 tools=[](只会聊天)。这就是「能干活的 AI」和「只会说话的 AI」的区别。
Step 7 · Day06 §3 + §4

7对话接口 + SSE 打字机:让网页上真的“聊起来”

文件:app/modules/chat/(对应讲义 3.x + 4.x)

现在要让网页和接线员通话。最简单是前端发一句话、等整段答案 —— 但那要等很久。更好的做法是 SSE:AI 每吐一个字,服务器就推一个字给网页,于是像“打字机”一样一个字一个字蹦出来。

打个比方:普通请求 = 你把信寄出去,等一周收到一厚沓回信;SSE = 对方开了直播,你说一句话,对面吸取时一个字一个字念给你听。

第一步,定义网页要交过来的「格式」(它说会话号是多少、说了什么):

chat/schemas.py
class ChatRequest(BaseModel):
    thread_id: UUID = Field(description='会话ID')
    message: str  = Field(min_length=1, description='用户消息')
🔍 每行在干嘛
代码大白话解释
class ChatRequest(BaseModel)定义「对话请求」的收件格式(约定前端必须按这个来)。
thread_id: UUID = Field(description='会话ID')必填项:会话号。拿哪把抽屉钥匙(对应第 5 步的记事本)。
message: str = Field(min_length=1)必填项:用户说的话,至少 1 个字,不能空。

第二步,理解 SSE 长什么样 —— 就是一段一段「事件块」拼起来:

SSE 数据格式
event: message
data: 你

event: message
data: 好

event: done
data: [done]
🔍 每行在干嘛
代码大白话解释
event: message这坨数据叫「message」类型事件,表示来了一句聊天内容。
data: 你 / data: 好真正的内容(一个字/一小段)。前端把它逐个接到气泡尾巴上 → 打字机。
event: done, data: [done]最后发个「结束」信号,告诉前端话说完了,该收尾了。

第三步,接口层(就是网页来敲门的那扇门):

chat/router.py
def get_service(request, session = Depends(get_session)):
    service = ChatService(request.app.state.agent, session)
    return service

@router.post("/chat", response_class=EventSourceResponse)
async def chat(chat_request: ChatRequest,
   user_id: Annotated[int, Header(alias="x-user-id")],
   service: ChatService = Depends(get_service)):
    message = service.chat_stream(chat_request, user_id)
    async for sse in message:
       yield sse
🔍 每行在干嘛
代码大白话解释
get_service(request, session=Depends(get_session))一个「开工后勤」函数:每个请求进来,先把对话服务准备好。
ChatService(request.app.state.agent, session)把挂在 app 上的那个接线员 Agent 塞给对话服务。第 9 步会看到 agent 怎么挂上去的。
@router.post("/chat", response_class=EventSourceResponse)声明:这是个 POST 请求接口,地址是 /chat,返回却是「SSE 流」这种特殊格式。
user_id = Header(alias="x-user-id")从 HTTP 请求头里的 x-user-id 字段取「这人是哪个用户」。
async for sse in message: yield sse把服务一层层吐出来的每个 SSE 事件,原封不动再转手给网页。一步步转发。

第四步,真正干活的核心(业务层)——四步:查身份 → 交话 → 逐字推 → 结尾。

chat/service.py(完整)
async def chat_stream(self, chat_request, user_id):
    # 1.校验:这个会话是你吗
    chat_thread = await self.repository.find_owned(chat_request.thread_id, user_id)
    if chat_thread is None:
        raise ChatThreadNotFoundError()

    # 2.告诉 Agent 用哪把钥匙
    config = {'configurable': {'thread_id': str(chat_request.thread_id)}}
    stream = await self.agent.astream_events(
        {'messages': [HumanMessage(chat_request.message)]},
        config, version='v3')

    # 3.逐字推给前端
    async for message in stream.messages:
        async for chunk in message.text:
            yield ServerSentEvent(data=chunk, event='message')

    # 4.说完了,结束
    yield ServerSentEvent(data=f'[done]', event='done')
🔍 每行在干嘛
代码大白话解释
async def chat_stream(self, chat_request, user_id)核心函数,是个「生成器」:一边算一边往外吐结果。
await self.repository.find_owned(thread_id, user_id)查一下:这个会话存不存在、是否属于你(防止偷看别人会话)。
if chat_thread is None: raise ChatThreadNotFoundError()查不到就抛异常「会话不存在」。异常由 main.py 统一转成 JSON 回复给前端。
config = {'configurable': {'thread_id': str(...)}}告诉 Agent:用这个 thread_id 当「抽屉钥匙」,去记事本里续写/接上对话。
stream = await self.agent.astream_events({...}, config, version='v3')让 Agent 开始「边想边长」地回话。version='v3' 是新一代流式协议,专门按 token 逐字吐。
async for message in stream.messages按顺序接收 Agent 吐出的一个个内容块。
async for chunk in message.text: yield ServerSentEvent(data=chunk, event='message')从里面再抠出「文字增量」,包成一个 message 事件推给前端 → 打字机。
yield ServerSentEvent(data='[done]', event='done')话讲完了,发个「我说完了」的信号收尾。
一句话回顾:先确认「没偷看别人的会话」,再拿钥匙找记忆、把话交给 Agent,Agent 说一个字推一个字,最后说「完」。(这就是讲义 §3 基础对话 + §4 SSE 合二为一的版本。)
Step 8 · Day06 §5(止于 5.5)

8会话历史:把以前聊过的“旧账”翻出来 ★5.5

文件:app/modules/chat_thread/(对应讲义 5.1~5.5)

聊过天,下次打开网页想看看之前聊了啥。谁来管这事?chat_thread 模块。它既管「会话名片」(有几个会话、谁开的、标题是啥),也负责把聊天记录读回来。我们先看怎么「翻历史」(5.5)。

A. 会话名片长什么样 —— 话说这个类其实你已经见过了:它继承了第 3 步的老祖宗和两个时间戳工具,所以自动有了创建/修改时间。

chat_thread/models.py
class ChatThread(Base, CreateAtMixin, UpdateAtMixin):
    __tablename__ = "chat_threads"
    id: Mapped[UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, default=uuid4)
    user_id: Mapped[int] = mapped_column(BigInteger)
    title:  Mapped[str] = mapped_column(String(200), default="新会话", server_default="新会话")
🔍 每行在干嘛
代码大白话解释
class ChatThread(Base, CreateAtMixin, UpdateAtMixin)定义「会话」这张表:继承祖类 + 两个时间戳工具,白捡 created_at/updated_at。
__tablename__ = "chat_threads"对应数据库里那张表的名字。
id: Mapped[UUID] = mapped_column(PG_UUID, primary_key=True, default=uuid4)主键 id:一个自动生成的随机号(UUID),每个会话一个,绝不重复。
user_id: Mapped[int] = mapped_column(BigInteger)这「是谁开的会话」——存在一个很大的整数里。
title: Mapped[str] = mapped_column(String(200), default="新会话")会话标题,最长 200 字,不填默认叫「新会话」。

B. 安全守卫 find_owned —— 一句查询,保证「会话是你的」:

chat_thread/repository.py
async def find_owned(self, thread_id: UUID, user_id: int) -> ChatThread | None:
    return await self.session.scalar(
        select(ChatThread).where(
            ChatThread.id == thread_id,
            ChatThread.user_id == user_id,
        ))
🔍 每行在干嘛
代码大白话解释
async def find_owned(self, thread_id, user_id)「查一下这条会话并且确认归你」的方法。返回值可能是会话,也可能是 None。
session.scalar(select(ChatThread).where(...))执行一条查询,只取第一条结果。
ChatThread.id == thread_id条件1:会话 id 要对上。
ChatThread.user_id == user_id条件2:还得属于当前用户。两个条件一起满足才算数。
-> ChatThread | None可能查到(返回会话),也可能查不到(返回 None = 不存在或不是你的)。

C. 会话的增改删(service.py) —— 用 async with session.begin() 起一个事务块,最省心:正常走完自动保存,中途出错自动回滚,不会留半个数据。

chat_thread/service.py
async def add(self, user_id, title):
    async with self.session.begin():
        chat_thread = ChatThread(user_id=user_id, title=title)
        await self.repository.add(chat_thread)
        return chat_thread

async def rename(self, thread_id, user_id, title):
    async with self.session.begin():
        thread = await self.repository.find_owned(thread_id, user_id)
        if thread is None:
            raise ChatThreadNotFoundError
        thread.title = title
        await self.session.flush()
        await self.session.refresh(thread)
    return thread
🔍 每行在干嘛
代码大白话解释
async with self.session.begin():开一个「事务」小闸门:里面代码正常跑完就自动提交(保存),报错就自动全部撤销。
ChatThread(user_id=user_id, title=title)在内存里「捏一个会话对象」(对应一张新名片)。
await self.repository.add(chat_thread)把这张名片«插入数据库»。
find_owned(...)改名之前先确认「这会话是我的」。
if thread is None: raise ChatThreadNotFoundError不是你的 → 直接抛异常停下,绝不让你动别人的东西。
thread.title = title改标题字段。因为对象一直在被跟踪,改动会自动变成 UPDATE。
flush()把改动真正送到数据库执行(但还没最终提交)。
refresh(thread)重新从数据库读一遍最新值,拿到数据库回写后的最终数据。

D. ★ 5.5 —— 翻历史(router.py · get_history_message)
最妙的地方在这里:聊天记录并不存在一张普通表里,而是存在 Agent 自己的「检查点状态」里(第 5 步那个记事本)。所以「翻历史」= 让 Agent 把某个 thread_id 的记事本内容 dump 出来,再挑出消息。

chat_thread/router.py · 5.5 查询历史
@router.get('/{thread_id}/messages')
async def get_history_message(request, thread_id):
    agent = request.app.state.agent
    config = {'configurable': {'thread_id': thread_id}}
    snapshot = await agent.aget_state(config)

    messages = snapshot.values.get('messages', [])
    chat_messages = []
    for message in messages:
        if isinstance(message, HumanMessage):       role = 'user'
        elif isinstance(message, AIMessage):         role = 'assistant'
        content = message.text
        if content:
            chat_messages.append(ChatResponse(role=role, content=content))

    return ChatHistoryResponse(thread_id=thread_id, messages=chat_messages)
🔍 5.5 这 12 行,每行在干嘛
代码大白话解释
@router.get('/{thread_id}/messages')这是「查询会话历史」接口:GET 地址 /chat-threads/{会话号}/messages(5.5)。
agent = request.app.state.agent把应用启动时挂好的那个接线员 Agent 拿过来。第 9 步会看到它是怎么挂上去的。
config = {'configurable': {'thread_id': thread_id}}指定「我要看的是哪个抽屉」——就是这个 thread_id 的记忆。
snapshot = await agent.aget_state(config)让 Agent 把那个抽屉里的「状态」整个倒出来 = 快照(含所有聊过的消息)。
messages = snapshot.values.get('messages', [])从快照里取「消息列表」字段;万一没有,就给个空列表兜底。
for message in messages:一条一条过这些历史消息。
isinstance(message, HumanMessage) → role='user'这条是用户发的 → 标记成「user(我)」。
elif isinstance(message, AIMessage) → role='assistant'这条是 AI 发的 → 标记成「assistant(AI)」。
content = message.text取出这条消息的正文文字。
if content:跳过空内容的(有些消息没文字,别掺进来)。
chat_messages.append(ChatResponse(role, content))把「谁说的 + 说了啥」存成一个条目,装进结果列表。
return ChatHistoryResponse(...)打包成约定好的「历史响应」结构返回给前端。
这就是今天的终点 5.5:「历史」不是第二张表,而是 Agent 自己早就边聊边记在检查点里了,这里只是把它读出来给你看。前端重新打开某个会话时调用这个接口,聊天记录就回来了。
Step 9 · Day06 收尾

9main.py:点火的瞬间,把零件全装上

文件:app/main.py(对应讲义 1.5 / 2.3「交给 FastAPI 管理」)

前面的零件都是散件,谁来一键组装+开机?main.py。它在服务启动那一刻,把「连数据库→建记事本→造 Agent→挂到全局」按顺序做完;关闭那一刻,把资源都还回去。

main.py 关键片段
@asynccontextmanager
async def lifespan(app):
    await check_database()
    checkpointer = await init_checkpointer()
    app.state.agent = init_agent(checkpointer)
    yield
    await close_database(); await close_checkpointer()

app.add_middleware(CORSMiddleware,
    allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"])

@app.exception_handler(ApplicationError)
async def handle_application_error(request, exc):
    return JSONResponse(status_code=exc.status_code,
        content={"code": exc.code, "message": exc.message})

app.include_router(chat_thread_router)
app.include_router(chat_router)
🔍 每行在干嘛
代码大白话解释
@asynccontextmanager / def lifespan(app)注册一个「生命周期钩子」:服务开始前、结束后各做一段准备/收尾。
await check_database()先捅一下业务库,确认数据库活着,否则后面全白搭。
checkpointer = await init_checkpointer()建好记事本(第 5 步)。
app.state.agent = init_agent(checkpointer)用记事本造出 Agent,并挂到 app.state.agent这就是第 7、8 步能 request.app.state.agent 拿到它的原因。
yield此处分界:到这里应用开始正式对外服务(一直跑),直到被关闭。
await close_database(); await close_checkpointer()关闭时把业务库池子、记事本池子都收掉,释放资源。
app.add_middleware(CORSMiddleware, allow_origins=[...])跨域白名单:只允许前端地址(localhost:5173)来访问,别的域名进不来。
@app.exception_handler(ApplicationError)「统一接盘」所有业务异常,转换成统一格式的 JSON 返回给前端。
app.include_router(chat_thread_router)把「档案柜(会话+翻历史)」服务的所有接口登记注册。
app.include_router(chat_router)把「传声筒(SSE 对话)」服务的所有接口登记注册。

把整条路走一遍(到 5.5)

前端POST /chat-threads(带 x-user-id)→ 建会话,拿到 thread_id
前端POST /chat {thread_id, message}(SSE)
chat.servicefind_owned 确认是本人会话
chat.serviceastream_events(v3) + thread_id 记忆 → Agent 逐字回
chat.service逐字包 SSE → 前端打字机;最后 done
前端GET /chat-threads/{id}/messages(5.5)→ 掏出历史

自己动手测一测

curl 验证(端口以你 .env 为准,默认 8001)
# 1. 建会话
curl -X POST http://localhost:8001/api/v1/chat-threads \
     -H "Content-Type: application/json" -H "x-user-id: 1" -d '{"title":"我的咨询"}'

# 2. 发一句话(会以 SSE 逐字返回)
curl -N -X POST http://localhost:8001/api/v1/chat \
     -H "Content-Type: application/json" -H "x-user-id: 1" \
     -d '{"thread_id":"$TID","message":"你好"}'

# 3. 5.5 翻历史
curl -H "x-user-id: 1" http://localhost:8001/api/v1/chat-threads/$TID/messages
观察重点:同一个 thread_id 聊过的内容记得住(记事本生效);因为没有工具,接线员只按「安心保」人设纯聊天,不会查产品、不会存方案。
Coming next · 下集预告

你现在「已会 / 还没会」一览

把 Day05 MCP → Day06 5.5 的账给你摆清楚。

能力对应课件你的代码现况
给 Agent 接外部 MCP 工具Day05notebook 会,正式项目 tools=[] 还没接
配置中心 + 数据库地基Day06 准备✅ 已有
短期记忆 CheckpointerDay06 §1✅ 已有 (infra/checkpointer.py)
初始化 AgentDay06 §2✅ 已有(tools 空)
SSE 流式对话Day06 §3+§4✅ 已有 (chat 模块)
查询会话历史Day06 §5.5✅ 已有 (get_history_message)
保险查询工具Day06 §6只有查询服务,还没包成工具
生成并保存方案Day06 §7表已建,代码没写

需要的话,我可以按你现有代码风格把这「下集」也做成同样的大白话逐行教程。

Day05 MCP → Day06 5.5 · 大白话逐行版 — 内容全部源自你仓库的真实代码。