把新版 LangChain Agent 做成真正应用:Memory、Streaming、SSE 与 Structured Output

15 阅读7分钟

前两篇解决了:

Agent 怎么执行
Tool 怎么调用
State 怎么管理
Context 怎么注入
Middleware 怎么扩展

但到这里,Agent 仍然有几个很明显的问题:

不会自动记住上一轮对话
用户必须等最终答案一次性返回
前端不知道 Agent 正在干什么
最终输出还是自由文本

这一篇我们把它真正升级成一个比较完整的 AI 后端服务。

核心包括:

Checkpointer
thread_id
短期记忆
Streaming
Tool Custom Events
FastAPI SSE
Structured Output

一、为什么 Agent 默认没有真正的多轮记忆?

第一次:

用户:
我叫猪头

FastAPI 调:

agent.ainvoke(
    {
        "messages": [
            {
                "role": "user",
                "content": "我叫猪头"
            }
        ]
    }
)

第二次:

我叫什么?

如果你又只传:

{
    "messages": [
        {
            "role": "user",
            "content": "我叫什么?"
        }
    ]
}

那么第二次 Agent 根本不知道第一次发生了什么。

因为:

HTTP Request 1
结束

HTTP Request 2
重新开始

所以需要:

Checkpointer

二、Checkpointer 是什么?

可以直接记:

State
=
保存什么

Checkpointer
=
怎么保存和恢复

thread_id
=
保存的是哪一个会话

开发阶段可以:

from langgraph.checkpoint.memory import (
    InMemorySaver,
)


checkpointer = InMemorySaver()

然后:

agent = create_agent(
    model=model,
    tools=tools,
    checkpointer=checkpointer,
)

接下来每次调用:

config = {
    "configurable": {
        "thread_id": "chat_001"
    }
}

再:

await agent.ainvoke(
    state,
    config=config,
)

三、thread_id 如何串起多轮对话?

第一次:

thread_id = chat_001

我叫猪头

最终 State:

messages:
[
    HumanMessage("我叫猪头"),
    AIMessage("好的")
]

Checkpointer 保存:

chat_001
↓
State

第二次:

thread_id = chat_001

我叫什么?

Runtime 根据:

chat_001

恢复之前 State。

于是模型实际上看到:

HumanMessage("我叫猪头")
AIMessage("好的")
HumanMessage("我叫什么?")

就可以回答:

你叫猪头。

如果换成:

thread_id = chat_002

则是另外一份完全独立的 State。


四、State、Context、Config 再区分一次

现在调用可能变成:

await agent.ainvoke(
    state,
    config=config,
    context=context,
)

三个概念:

state
=
Agent 运行数据

context
=
业务运行环境

config
=
LangGraph Runtime 配置

例如:

state = {
    "messages": [...]
}
context = AgentContext(
    user_id="001",
    role="admin",
)
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
用户偏好中文

那属于:

Long-term Store

不是 Checkpointer。

可以简单记:

Checkpointer
=
Conversation Memory

Store
=
User Memory

六、为什么 InMemorySaver 不能上生产?

因为:

服务重启
↓
内存消失

而且多个 Worker:

Worker A
Worker B

内存互不共享。

用户第一次请求可能到:

Worker A

第二次请求到:

Worker B

历史就没了。

生产环境通常应该换:

PostgreSQL
Redis

等共享持久化方案。


七、下一步:Streaming

普通接口:

用户请求
↓
等待 8 秒
↓
答案突然出现

对于 Agent 来说体验很差。

因为 Agent 中间可能正在:

搜索
查询数据库
调用天气
计算
分析

更好的体验应该是:

正在查询天气...
↓
天气查询完成
↓
正在计算...
↓
最终答案逐字输出

新版 Agent 可以:

agent.astream()

进行流式处理。


八、三个非常重要的 Stream Mode

最常用的是:

messages
custom
updates

messages

用于:

LLM Token / Message Chunk

例如:

"上"
"海"
"今"
"天"

custom

用于:

Tool 或 Node 主动发送自定义事件

例如:

正在查询上海天气

updates

用于:

Graph Node 执行后的 State 更新

更适合:

Agent Debug
流程 UI
Observability

九、Tool 如何主动给前端发状态?

使用:

runtime.stream_writer

例如:

@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 有两条输出线。

return result

走:

Tool
↓
ToolMessage
↓
LLM

而:

runtime.stream_writer.write(...)

走:

Tool
↓
custom stream
↓
FastAPI
↓
前端

这两个目的完全不同。


十、FastAPI SSE

我们可以创建:

POST /api/chat/stream

使用:

EventSourceResponse
ServerSentEvent

需要注意新版 FastAPI SSE 的写法。

路由本身应该直接:

yield

例如:

@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={}
    )

不要再:

async def event_generator():
    ...

return EventSourceResponse(
    event_generator()
)

否则在新版 SSE 路由模式下可能出现:

'coroutine' object is not iterable

十一、为什么不用 json.dumps()

新版:

ServerSentEvent(
    data={
        "content": "hello"
    }
)

可以直接传 dict。

框架会负责序列化。

如果自己:

json.dumps(...)

再传进去,可能造成:

JSON string
↓
再次 JSON encode

最终前端收到:

"{"content":"hello"}"

需要多 parse 一次。

所以:

data={...}

即可。


十二、前端为什么不能直接用 EventSource?

因为原生:

new EventSource(...)

主要用于:

GET

但聊天接口一般需要:

POST
+
Body
+
Authorization

例如:

{
  "message": "...",
  "thread_id": "..."
}

所以更常用:

fetch
+
ReadableStream

例如:

const response = await fetch(
    "/api/chat/stream",
    {
        method: "POST",
        body: JSON.stringify({
            message,
            thread_id,
        }),
    }
)

然后:

const reader =
    response.body!.getReader()

const decoder =
    new TextDecoder()

持续:

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

没有你的业务边界概念。

服务端:

event: token
data: {"content":"LangChain"}

客户端第一次可能收到:

event: token
data: {"cont

第二次:

ent":"LangChain"}

所以前端必须有:

buffer
+
SSE Parser

而不能简单假设:

一个 read()
=
一个完整 Event

这和 TCP 拆包、粘包的思想非常像。


十四、Structured Output

Streaming 解决的是:

用户体验

但另一个问题还存在:

result["messages"][-1].content

仍然是一段自由文本。

前端如果需要:

answer
category
confidence

就需要结构化输出。

例如:

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,
    )

然后:

agent = create_agent(
    model=model,
    tools=tools,

    response_format=
        AgentResponse,
)

最终:

result[    "structured_response"]

得到:

AgentResponse(
    answer="上海今天多云",
    category="weather",
    confidence=0.96,
)

十五、Tool Schema 和 Structured Output 不一样

Tool:

class CalculatorInput(BaseModel):

解决的是:

LLM
↓
Tool

参数结构。

例如:

{
  "a": 123,
  "b": 456,
  "operator": "multiply"
}

而:

class AgentResponse(BaseModel):

解决的是:

Agent
↓
应用程序

输出结构。

例如:

{
  "answer": "...",
  "category": "weather",
  "confidence": 0.95
}

所以:

Tool Schema
=
工具输入契约

Structured Response
=
Agent 输出契约

十六、ProviderStrategy 和 ToolStrategy

新版 LangChain 可以通过 Provider 原生能力:

ProviderStrategy

生成结构化输出。

也可以通过:

ToolStrategy

把结构化输出模拟成一次特殊的 Tool Calling。

大多数时候直接:

response_format=AgentResponse

即可。

让 LangChain 根据模型能力自动选择。

这样以后从:

OpenAI

切换到:

其他 Provider

业务层不用改太多。


十七、Structured Output 只保证格式,不保证事实

这个非常重要。

例如模型输出:

{
  "confidence": 0.99
}

Schema 可以保证:

confidence 是数字
并且在 0~1

但不能保证:

99% 真的是统计意义上的正确率

也就是说:

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 又可以通过:

ToolRuntime
├── context
├── state
├── tool_call_id
└── stream_writer

和整个 Runtime 交互。

这已经不再只是:

调用一下 LLM API

而是一个真正:

有状态
有工具
有权限
有生命周期
有流式事件
有结构化契约

的 Agent Backend。


十九、学完这三篇之后,你应该具备的知识结构

第一层:

create_agent
Tool Calling
ToolMessage
Agent Loop

第二层:

State
Reducer
Context
ToolRuntime
Command
Middleware

第三层:

Checkpointer
thread_id
Streaming
SSE
Structured Output

如果你把这三层吃透,新版 LangChain Agent 的核心主干其实已经掌握了大半。

下一阶段再继续学习:

Dynamic Tools
Dynamic Model
官方 Middleware
Long-term Store
Context Engineering
Human-in-the-loop
LangSmith
MCP
Agentic RAG
Multi-Agent
Deep Agents

会顺很多,因为后面的能力基本都是在这套 Runtime 之上继续扩展。