前两篇解决了:
Agent 怎么执行
Tool 怎么调用
State 怎么管理
Context 怎么注入
Middleware 怎么扩展
但到这里,Agent 仍然有几个很明显的问题:
不会自动记住上一轮对话
用户必须等最终答案一次性返回
前端不知道 Agent 正在干什么
最终输出还是自由文本
这一篇我们把它真正升级成一个比较完整的 AI 后端服务。
核心包括:
vbnet
Checkpointer
thread_id
短期记忆
Streaming
Tool Custom Events
FastAPI SSE
Structured Output
一、为什么 Agent 默认没有真正的多轮记忆?
第一次:
用户:
我叫猪头
FastAPI 调:
py
agent.ainvoke(
{
"messages": [
{
"role": "user",
"content": "我叫猪头"
}
]
}
)
第二次:
我叫什么?
如果你又只传:
py
{
"messages": [
{
"role": "user",
"content": "我叫什么?"
}
]
}
那么第二次 Agent 根本不知道第一次发生了什么。
因为:
vbscript
HTTP Request 1
结束
HTTP Request 2
重新开始
所以需要:
Checkpointer
二、Checkpointer 是什么?
可以直接记:
ini
State
=
保存什么
Checkpointer
=
怎么保存和恢复
thread_id
=
保存的是哪一个会话
开发阶段可以:
py
from langgraph.checkpoint.memory import (
InMemorySaver,
)
checkpointer = InMemorySaver()
然后:
ini
agent = create_agent(
model=model,
tools=tools,
checkpointer=checkpointer,
)
接下来每次调用:
py
config = {
"configurable": {
"thread_id": "chat_001"
}
}
再:
py
await agent.ainvoke(
state,
config=config,
)
三、thread_id 如何串起多轮对话?
第一次:
py
thread_id = chat_001
我叫猪头
最终 State:
py
messages:
[
HumanMessage("我叫猪头"),
AIMessage("好的")
]
Checkpointer 保存:
chat_001
↓
State
第二次:
ini
thread_id = chat_001
我叫什么?
Runtime 根据:
chat_001
恢复之前 State。
于是模型实际上看到:
scss
HumanMessage("我叫猪头")
AIMessage("好的")
HumanMessage("我叫什么?")
就可以回答:
你叫猪头。
如果换成:
ini
thread_id = chat_002
则是另外一份完全独立的 State。
四、State、Context、Config 再区分一次
现在调用可能变成:
py
await agent.ainvoke(
state,
config=config,
context=context,
)
三个概念:
ini
state
=
Agent 运行数据
context
=
业务运行环境
config
=
LangGraph Runtime 配置
例如:
py
state = {
"messages": [...]
}
py
context = AgentContext(
user_id="001",
role="admin",
)
py
config = {
"configurable": {
"thread_id": "chat_001"
}
}
特别要注意:
role
permissions
user_id
这类鉴权信息,不应该依赖历史 Checkpoint。
因为用户今天是:
admin
明天权限可能已经被撤销。
所以每次 HTTP 请求都应该重新:
验证 JWT
↓
创建 Context
而不是从旧 State 恢复。
五、Checkpointer 不是 Long-Term Memory
这是一个很容易混淆的地方。
Checkpointer
更接近:
同一 Thread 内的短期记忆。
例如:
thread_001
保存:
聊天历史
任务步骤
当前 topic
但如果明天创建:
thread_999
还希望 Agent 知道:
用户喜欢 React
用户偏好中文
那属于:
kotlin
Long-term Store
不是 Checkpointer。
可以简单记:
ini
Checkpointer
=
Conversation Memory
Store
=
User Memory
六、为什么 InMemorySaver 不能上生产?
因为:
服务重启
↓
内存消失
而且多个 Worker:
css
Worker A
Worker B
内存互不共享。
用户第一次请求可能到:
css
Worker A
第二次请求到:
css
Worker B
历史就没了。
生产环境通常应该换:
PostgreSQL
Redis
等共享持久化方案。
七、下一步:Streaming
普通接口:
用户请求
↓
等待 8 秒
↓
答案突然出现
对于 Agent 来说体验很差。
因为 Agent 中间可能正在:
搜索
查询数据库
调用天气
计算
分析
更好的体验应该是:
erlang
正在查询天气...
↓
天气查询完成
↓
正在计算...
↓
最终答案逐字输出
新版 Agent 可以:
scss
agent.astream()
进行流式处理。
八、三个非常重要的 Stream Mode
最常用的是:
vbnet
messages
custom
updates
messages
用于:
LLM Token / Message Chunk
例如:
arduino
"上"
"海"
"今"
"天"
custom
用于:
Tool 或 Node 主动发送自定义事件
例如:
正在查询上海天气
updates
用于:
Graph Node 执行后的 State 更新
更适合:
rust
Agent Debug
流程 UI
Observability
九、Tool 如何主动给前端发状态?
使用:
runtime.stream_writer
例如:
py
@tool
def get_weather(
city: str,
runtime: ToolRuntime,
):
runtime.stream_writer.write(
{
"type": "tool_status",
"tool": "get_weather",
"status": "start",
"message":
f"正在查询 {city} 天气",
}
)
result = query_weather(city)
runtime.stream_writer.write(
{
"type": "tool_status",
"tool": "get_weather",
"status": "success",
"message": "天气查询完成",
}
)
return result
这里 Tool 有两条输出线。
kotlin
return result
走:
Tool
↓
ToolMessage
↓
LLM
而:
lua
runtime.stream_writer.write(...)
走:
vbnet
Tool
↓
custom stream
↓
FastAPI
↓
前端
这两个目的完全不同。
十、FastAPI SSE
我们可以创建:
bash
POST /api/chat/stream
使用:
EventSourceResponse
ServerSentEvent
需要注意新版 FastAPI SSE 的写法。
路由本身应该直接:
arduino
yield
例如:
py
@router.post(
"/stream",
response_class=EventSourceResponse,
)
async def chat_stream(
request: ChatRequest,
):
async for mode, chunk in agent.astream(
{
"messages": [
{
"role": "user",
"content": request.message,
}
]
},
stream_mode=[
"messages",
"custom",
],
):
if mode == "custom":
yield ServerSentEvent(
event="status",
data=chunk,
)
elif mode == "messages":
message_chunk, metadata = chunk
content = message_chunk.content
if (
isinstance(content, str)
and content
):
yield ServerSentEvent(
event="token",
data={
"content": content
},
)
yield ServerSentEvent(
event="done",
data={}
)
不要再:
py
async def event_generator():
...
return EventSourceResponse(
event_generator()
)
否则在新版 SSE 路由模式下可能出现:
csharp
'coroutine' object is not iterable
十一、为什么不用 json.dumps()?
新版:
py
ServerSentEvent(
data={
"content": "hello"
}
)
可以直接传 dict。
框架会负责序列化。
如果自己:
scss
json.dumps(...)
再传进去,可能造成:
typescript
JSON string
↓
再次 JSON encode
最终前端收到:
arduino
"{"content":"hello"}"
需要多 parse 一次。
所以:
ini
data={...}
即可。
十二、前端为什么不能直接用 EventSource?
因为原生:
scss
new EventSource(...)
主要用于:
sql
GET
但聊天接口一般需要:
diff
POST
+
Body
+
Authorization
例如:
json
{
"message": "...",
"thread_id": "..."
}
所以更常用:
diff
fetch
+
ReadableStream
例如:
ts
const response = await fetch(
"/api/chat/stream",
{
method: "POST",
body: JSON.stringify({
message,
thread_id,
}),
}
)
然后:
ts
const reader =
response.body!.getReader()
const decoder =
new TextDecoder()
持续:
ts
while (true) {
const {
value,
done
} = await reader.read()
if (done) break
const text =
decoder.decode(
value,
{
stream: true
}
)
console.log(text)
}
十三、为什么不能把一次 reader.read() 当成一个 SSE Event?
因为:
HTTP/TCP Chunk
没有你的业务边界概念。
服务端:
css
event: token
data: {"content":"LangChain"}
客户端第一次可能收到:
makefile
event: token
data: {"cont
第二次:
css
ent":"LangChain"}
所以前端必须有:
arduino
buffer
+
SSE Parser
而不能简单假设:
scss
一个 read()
=
一个完整 Event
这和 TCP 拆包、粘包的思想非常像。
十四、Structured Output
Streaming 解决的是:
用户体验
但另一个问题还存在:
css
result["messages"][-1].content
仍然是一段自由文本。
前端如果需要:
answer
category
confidence
就需要结构化输出。
例如:
ts
from typing import Literal
from pydantic import BaseModel, Field
class AgentResponse(BaseModel):
answer: str
category: Literal[
"general",
"weather",
"calculation",
"account",
"order",
]
confidence: float = Field(
ge=0,
le=1,
)
然后:
py
agent = create_agent(
model=model,
tools=tools,
response_format=
AgentResponse,
)
最终:
css
result[ "structured_response"]
得到:
ini
AgentResponse(
answer="上海今天多云",
category="weather",
confidence=0.96,
)
十五、Tool Schema 和 Structured Output 不一样
Tool:
kotlin
class CalculatorInput(BaseModel):
解决的是:
LLM
↓
Tool
参数结构。
例如:
css
{
"a": 123,
"b": 456,
"operator": "multiply"
}
而:
kotlin
class AgentResponse(BaseModel):
解决的是:
Agent
↓
应用程序
输出结构。
例如:
py
{
"answer": "...",
"category": "weather",
"confidence": 0.95
}
所以:
ini
Tool Schema
=
工具输入契约
Structured Response
=
Agent 输出契约
十六、ProviderStrategy 和 ToolStrategy
新版 LangChain 可以通过 Provider 原生能力:
ProviderStrategy
生成结构化输出。
也可以通过:
ToolStrategy
把结构化输出模拟成一次特殊的 Tool Calling。
大多数时候直接:
ini
response_format=AgentResponse
即可。
让 LangChain 根据模型能力自动选择。
这样以后从:
OpenAI
切换到:
其他 Provider
业务层不用改太多。
十七、Structured Output 只保证格式,不保证事实
这个非常重要。
例如模型输出:
json
{
"confidence": 0.99
}
Schema 可以保证:
confidence 是数字
并且在 0~1
但不能保证:
shell
99% 真的是统计意义上的正确率
也就是说:
ini
Structured Output
=
Shape Reliability
不等于:
Truth Reliability
所以像:
used_tools
真实执行耗时
Token 数量
权限结果
计费
这类信息应该由系统 Runtime / Middleware 提供,而不是让模型自己生成。
十八、目前整个 Agent 架构
现在我们的项目已经形成:
React
│
▼
FastAPI
│
├──────── JWT / Context
│
├──────── thread_id
│
▼
create_agent
│
├──────── Model
│
├──────── Tools
│
├──────── Middleware
│
├──────── State
│
├──────── Checkpointer
│
├──────── Streaming
│
└──────── Structured Output
Tool 又可以通过:
perl
ToolRuntime
├── context
├── state
├── tool_call_id
└── stream_writer
和整个 Runtime 交互。
这已经不再只是:
调用一下 LLM API
而是一个真正:
有状态
有工具
有权限
有生命周期
有流式事件
有结构化契约
的 Agent Backend。
十九、学完这三篇之后,你应该具备的知识结构
第一层:
vbnet
create_agent
Tool Calling
ToolMessage
Agent Loop
第二层:
State
Reducer
Context
ToolRuntime
Command
Middleware
第三层:
Checkpointer
thread_id
Streaming
SSE
Structured Output
如果你把这三层吃透,新版 LangChain Agent 的核心主干其实已经掌握了大半。
下一阶段再继续学习:
sql
Dynamic Tools
Dynamic Model
官方 Middleware
Long-term Store
Context Engineering
Human-in-the-loop
LangSmith
MCP
Agentic RAG
Multi-Agent
Deep Agents
会顺很多,因为后面的能力基本都是在这套 Runtime 之上继续扩展。