0今天这一大段,到底在搭什么?
先别管代码。用一句话讲清楚你这两天在做的**一件事**,再一条路一条路把它走完。
咱把「保险顾问 Agent」当成一家「接线员服务」来理解,每个角色各管一段:
整个项目目录长这样,我们后面一步一个文件地过:
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 流式对话1MCP:怎么给 AI 装上「能干活的手」
来源:你仓库 notebook agent-service/notebooks/day05/11.mcp.ipynb
AI 光会「说」,不会「做」(查天气、查高铁票)。想让它做,就得给它工具。MCP 就是一套统一规格的「工具插槽」:别人做好一个个「工具服务器」,你按规矩把 AI 插上去,它就会用了。
get_tools() 是把 U 盘插上,create_agent(tools) 是告诉 AI「现在你有这些 U 盘可以用」。你在 notebook 里是这么写的——一次插两个外挂:
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('帮我查下周四从郑州到北京的高铁票?')]
})| 代码 | 大白话解释 |
|---|---|
| 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 请进正式项目」。2config.py:项目所有的「开关和钥匙」
文件:app/core/config.py
一个项目要跑起来,得知道「数据库在哪、用什么 AI、端口多少、日志多细」。这些从 .env 环境变量里读,统一放到全局的 settings 对象里,谁要用谁直接拿。
关键点在这两个被「自动算出来」的数据库地址:
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 大脑。后面每个文件都靠它们。3Base 祖类 + 连接池:数据库的「地基和自来水管」
文件:common/models.py + infra/database.py
A. 表的「祖爷爷」(common/models.py) 所有业务表都要是个「类」,这些类都从同一个老祖宗 Base 生出来,就能统一被数据库认识。再配两个「混入工具」,让每张表自动带创建/修改时间,不用每张表都写一遍。
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) 数据库连接很贵,不能每个请求都新开一条。于是提前备好一「池子」连接,谁要就给谁一条。
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 交出去用,请求结束自动关闭归还池子。等于「用前借、用后还」。 |
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 个分类(未来「候选产品查询」全靠它分类):
chat_threads 表和即将看到的 checkpointer 表。产品表和保险方案表是后面的戏。5checkpointer:帮 AI 记住「上次聊到哪」的记事本
文件:app/infra/checkpointer.py(对应讲义 1.1~1.5)
AI 每次对话天生「失忆」。怎么记住?想法是:用一个电话号码(thread_id 会话号)当抽屉号,把每次聊天的状态写进数据库这个「抽屉」。下次还拿同一个号,就把记录捞出来接着聊。
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() | 程序关停时调用,把连接池关掉、还资源,免得泄露。 |
6初始化保险顾问 Agent:把「接线员」请出来
文件:app/agents/insurance_advisor_agent.py(对应讲义 2.1~2.3)
这个文件就干一件事:造出我们的保险顾问接线员。注意它跟第 1 步 MCP 长的像,唯一差别在 tools。
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 | 把拼好的接线员交出去。 |
tools=tools(会查票会看时间),这里 tools=[](只会聊天)。这就是「能干活的 AI」和「只会说话的 AI」的区别。7对话接口 + SSE 打字机:让网页上真的“聊起来”
文件:app/modules/chat/(对应讲义 3.x + 4.x)
现在要让网页和接线员通话。最简单是前端发一句话、等整段答案 —— 但那要等很久。更好的做法是 SSE:AI 每吐一个字,服务器就推一个字给网页,于是像“打字机”一样一个字一个字蹦出来。
第一步,定义网页要交过来的「格式」(它说会话号是多少、说了什么):
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 长什么样 —— 就是一段一段「事件块」拼起来:
event: message
data: 你
event: message
data: 好
event: done
data: [done]| 代码 | 大白话解释 |
|---|---|
| event: message | 这坨数据叫「message」类型事件,表示来了一句聊天内容。 |
| data: 你 / data: 好 | 真正的内容(一个字/一小段)。前端把它逐个接到气泡尾巴上 → 打字机。 |
| event: done, data: [done] | 最后发个「结束」信号,告诉前端话说完了,该收尾了。 |
第三步,接口层(就是网页来敲门的那扇门):
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 事件,原封不动再转手给网页。一步步转发。 |
第四步,真正干活的核心(业务层)——四步:查身份 → 交话 → 逐字推 → 结尾。
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') | 话讲完了,发个「我说完了」的信号收尾。 |
8会话历史:把以前聊过的“旧账”翻出来 ★5.5
文件:app/modules/chat_thread/(对应讲义 5.1~5.5)
聊过天,下次打开网页想看看之前聊了啥。谁来管这事?chat_thread 模块。它既管「会话名片」(有几个会话、谁开的、标题是啥),也负责把聊天记录读回来。我们先看怎么「翻历史」(5.5)。
A. 会话名片长什么样 —— 话说这个类其实你已经见过了:它继承了第 3 步的老祖宗和两个时间戳工具,所以自动有了创建/修改时间。
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 —— 一句查询,保证「会话是你的」:
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() 起一个事务块,最省心:正常走完自动保存,中途出错自动回滚,不会留半个数据。
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 出来,再挑出消息。
@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)| 代码 | 大白话解释 |
|---|---|
| @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(...) | 打包成约定好的「历史响应」结构返回给前端。 |
9main.py:点火的瞬间,把零件全装上
文件:app/main.py(对应讲义 1.5 / 2.3「交给 FastAPI 管理」)
前面的零件都是散件,谁来一键组装+开机?main.py。它在服务启动那一刻,把「连数据库→建记事本→造 Agent→挂到全局」按顺序做完;关闭那一刻,把资源都还回去。
@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.service | ▶ | find_owned 确认是本人会话 |
| chat.service | ▶ | astream_events(v3) + thread_id 记忆 → Agent 逐字回 |
| chat.service | ▶ | 逐字包 SSE → 前端打字机;最后 done |
| 前端 | ▶ | GET /chat-threads/{id}/messages(5.5)→ 掏出历史 |
自己动手测一测
# 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▶你现在「已会 / 还没会」一览
把 Day05 MCP → Day06 5.5 的账给你摆清楚。
| 能力 | 对应课件 | 你的代码现况 |
|---|---|---|
| 给 Agent 接外部 MCP 工具 | Day05 | notebook 会,正式项目 tools=[] 还没接 |
| 配置中心 + 数据库地基 | Day06 准备 | ✅ 已有 |
| 短期记忆 Checkpointer | Day06 §1 | ✅ 已有 (infra/checkpointer.py) |
| 初始化 Agent | Day06 §2 | ✅ 已有(tools 空) |
| SSE 流式对话 | Day06 §3+§4 | ✅ 已有 (chat 模块) |
| 查询会话历史 | Day06 §5.5 | ✅ 已有 (get_history_message) |
| 保险查询工具 | Day06 §6 | 只有查询服务,还没包成工具 |
| 生成并保存方案 | Day06 §7 | 表已建,代码没写 |
下集:让接线员真正“动手”
§6:把你已有的候选产品查询包装成一个 LangChain 工具,再塞进 tools → 它就会“查产品”;§7:做 Context 和保存方案模块,把结果写进那两张已建好的 insurance_plans 表。这些按约定本次先不展开。
需要的话,我可以按你现有代码风格把这「下集」也做成同样的大白话逐行教程。