AI 电子伴侣
创建时间: 2026-04-18最后更新: 2026-04-21

1. 这一篇的位置

Vercel AI SDK 我们讲过「为什么 AI SDK 和 LangChain 是分层互补而不是替代」,并给出了三种协作模式,C(混合模式)是 AI 伴侣项目的选择。

前面 19 篇把 AI SDK 各层讲透了,LangChain 第 2 章和 LangGraph 第 3 章把后端编排讲透了。这一篇把它们真正组合起来,给出 AI 伴侣主管线的完整代码骨架。

具体回答四个问题:

  1. LangGraph 的 .stream() 事件,怎么翻译成 AI SDK 的 UIMessageStream 让前端 useChat 消费?
  2. AI SDK 的 tool()execute 里,怎么复用 LangChain 的 Retriever / Memory?
  3. 前端 UIMessage 历史,怎么同步成 LangGraph 的 State?
  4. 谁放哪里——哪些逻辑用 LangGraph、哪些用 AI SDK、哪些干脆用 Zod + generateObject?

读完这一篇,你就能在 重构端到端 AI Chat 里直接上手。

2. 明确每一层的职责

先把 AI 伴侣项目的每一层职责列清楚:

layers.txt
01
┌─────────────────────────────────────────────┐
02
│ 前端(Next.js 16 / React 19) │
03
│ - useChat + UI Parts │
04
│ - 思考态 / tool part / data part 渲染 │
05
│ - UI SDK 层 │
06
├─────────────────────────────────────────────┤
07
│ HTTP 层(Hono on Cloudflare Workers) │
08
│ - Route: /chat, /resume, /feedback │
09
│ - 认证 / 限流 / abort 传递 │
10
├─────────────────────────────────────────────┤
11
│ 桥接层(本章重点) │
12
│ - LangGraph Event ↔ AI SDK UIMessageStream │
13
│ - UIMessage ↔ LangGraph State │
14
├─────────────────────────────────────────────┤
15
│ 管线层(LangGraph StateGraph) │
16
│ 节点: │
17
│ ├─ loadContext(D1 加载画像 / 历史) │
18
│ ├─ emotionClassifier(generateObject) │
19
│ ├─ memoryRetrieval(LangChain Retriever) │
20
│ ├─ buildPrompt(prompts 包) │
21
│ ├─ llm(streamText + tools) │
22
│ └─ persistState(D1 / Vectorize 异步写回)│
23
├─────────────────────────────────────────────┤
24
│ 能力层 │
25
│ - @ai-sdk/openai / workers-ai-provider │
26
│ - @langchain/community Retriever / Memory │
27
│ - Cloudflare D1 / KV / Vectorize │
28
└─────────────────────────────────────────────┘

桥接层是本章的主角。它把两个生态缝合起来。

3. 桥接一:LangGraph Event → UIMessageStream

LangGraph 的 .stream({ streamMode: 'messages' }) 返回一个异步迭代器,每次 yield 一个 [chunk, metadata] 二元组。chunk 是 AIMessageChunk(LangChain 自己的类型),需要翻译成 AI SDK 的 UIMessageStream 协议。

核心思路是:包装成一个 ReadableStream,每个 chunk 产出对应的 UIMessage part。

bridge-graph-to-ui.ts
01
import { createUIMessageStream, createUIMessageStreamResponse } from 'ai'
02
import type { CompiledGraph } from '@langchain/langgraph'
03
import type { AIMessageChunk } from '@langchain/core/messages'
04
05
export function createGraphUIResponse(
06
graph: CompiledGraph,
07
initialState: CompanionState,
08
config: { signal?: AbortSignal },
09
) {
10
const uiStream = createUIMessageStream({
11
execute: async ({ writer }) => {
12
// 1. 跑 LangGraph 的流
13
for await (const [event, metadata] of graph.stream(initialState, {
14
streamMode: 'messages',
15
signal: config.signal,
16
})) {
17
// 2. 按 LangGraph 节点分发
18
const nodeName = metadata.langgraph_node
19
20
if (nodeName === 'emotionClassifier' && 'emotion' in event) {
21
// 情绪分类节点完成后,发一个 data part 到前端
22
writer.write({
23
type: 'data-emotion',
24
data: { primary: event.emotion, intensity: event.intensity },
25
})
26
}
27
28
if (nodeName === 'memoryRetrieval' && 'memories' in event) {
29
// 记忆检索节点完成后
30
writer.write({
31
type: 'data-memories-used',
32
data: { count: event.memories.length },
33
})
34
35
// 每条记忆作为 source-document part
36
for (const m of event.memories) {
37
writer.write({
38
type: 'source-document',
39
sourceId: m.id,
40
mediaType: 'application/json',
41
title: m.content.slice(0, 20),
42
})
43
}
44
}
45
46
if (nodeName === 'llm' && isAIMessageChunk(event)) {
47
// LLM 节点产 text-delta
48
if (event.content) {
49
writer.write({
50
type: 'text-delta',
51
id: event.id ?? 'txt_0',
52
delta: typeof event.content === 'string'
53
? event.content
54
: event.content.map((c) => (c.type === 'text' ? c.text : '')).join(''),
55
})
56
}
57
}
58
}
59
},
60
})
61
62
return createUIMessageStreamResponse({ stream: uiStream })
63
}

使用:

use-bridge.ts
01
app.post('/chat', async (c) => {
02
const { messages, sessionId } = await c.req.json()
03
const graph = createCompanionGraph(c.env)
04
05
const initialState: CompanionState = {
06
sessionId,
07
messages: langchainMessagesFromUI(messages), // 见桥接三
08
}
09
10
return createGraphUIResponse(graph, initialState, {
11
signal: c.req.raw.signal,
12
})
13
})

前端 useChat 消费这个响应,就能看到完整体验:情绪 badge → 记忆 hint → source 徽章 → 文本流式吐出。

4. 桥接二:AI SDK tool 内部用 LangChain

AI SDK 的 tool()execute 是一个 async 函数,里面想用什么都行。LangChain 的各种 Retriever / Memory / VectorStore 封装都挺成熟,直接 import 过来用:

tool-with-langchain.ts
01
import { tool } from 'ai'
02
import { z } from 'zod'
03
import { CloudflareVectorizeStore } from '@langchain/cloudflare'
04
import { CloudflareWorkersAIEmbeddings } from '@langchain/cloudflare'
05
06
export function searchMemoryTool(env: Env, userId: string) {
07
return tool({
08
description: '从长期记忆库里检索与当前对话相关的回忆',
09
inputSchema: z.object({
10
query: z.string().describe('检索的关键词或问题'),
11
topK: z.number().int().min(1).max(10).default(3),
12
}),
13
execute: async ({ query, topK }) => {
14
// 用 LangChain 的 Embeddings 和 VectorStore
15
const embeddings = new CloudflareWorkersAIEmbeddings({
16
binding: env.AI,
17
modelName: '@cf/baai/bge-m3',
18
})
19
20
const store = new CloudflareVectorizeStore(embeddings, {
21
index: env.VECTORIZE,
22
})
23
24
const retriever = store.asRetriever({
25
k: topK,
26
filter: { userId },
27
})
28
29
const docs = await retriever.invoke(query)
30
31
return docs.map((d) => ({
32
content: d.pageContent,
33
metadata: d.metadata,
34
}))
35
},
36
})
37
}

关键在于:从 AI SDK 的视角看,searchMemoryTool 就是一个普通 tool,LLM 根本不知道里面用了 LangChain。这就是分层的价值。

同样的模式还可以这么组合:

  • 用 LangChain 的 ConversationSummaryMemory 做历史摘要,包成 AI SDK 的 tool
  • 用 LangChain 的 MultiQueryRetriever 做多路检索,包成 AI SDK 的 tool
  • 用 LangChain 的 WebBaseLoader + RecursiveCharacterTextSplitter 处理 RAG 入库,放在定时任务里用,不走 tool

5. 桥接三:UIMessage ↔ LangChain BaseMessage

两套消息格式转换,两个方向。

5.1 UIMessage → LangChain BaseMessage

ui-to-langchain.ts
01
import type { UIMessage } from 'ai'
02
import { HumanMessage, AIMessage, SystemMessage, ToolMessage } from '@langchain/core/messages'
03
import type { BaseMessage } from '@langchain/core/messages'
04
05
export function langchainMessagesFromUI(uiMessages: UIMessage[]): BaseMessage[] {
06
const result: BaseMessage[] = []
07
08
for (const m of uiMessages) {
09
// 把 parts 里的 text 合并成一个字符串
10
const text = m.parts
11
.filter((p) => p.type === 'text')
12
.map((p: any) => p.text)
13
.join('')
14
15
// 处理 tool part(这里只处理简单文本,复杂情况参考下面)
16
if (m.role === 'user') {
17
result.push(new HumanMessage(text))
18
} else if (m.role === 'assistant') {
19
// 检查是否有 tool-invocation
20
const toolParts = m.parts.filter((p) => p.type.startsWith('tool-'))
21
22
if (toolParts.length > 0) {
23
// 一条 AIMessage 带 tool_calls
24
result.push(
25
new AIMessage({
26
content: text,
27
tool_calls: toolParts.map((p: any) => ({
28
id: p.toolCallId,
29
name: p.type.slice('tool-'.length),
30
args: p.input,
31
})),
32
}),
33
)
34
// 再加 ToolMessage 传结果
35
for (const p of toolParts) {
36
if ((p as any).state === 'output-available') {
37
result.push(
38
new ToolMessage({
39
tool_call_id: (p as any).toolCallId,
40
content: JSON.stringify((p as any).output),
41
}),
42
)
43
}
44
}
45
} else {
46
result.push(new AIMessage(text))
47
}
48
} else if (m.role === 'system') {
49
result.push(new SystemMessage(text))
50
}
51
}
52
53
return result
54
}

5.2 LangChain BaseMessage → UIMessage

反向转换用得比较少(前端一般只用 useChat 的消息数据源,不直接读 LangChain Message)。但持久化时可能需要——比如 LangGraph checkpointer 把 State 存进 Postgres,刷新页面时要把它转回 UIMessage 喂给 useChat

langchain-to-ui.ts
01
import type { UIMessage } from 'ai'
02
import type { BaseMessage } from '@langchain/core/messages'
03
import { AIMessage, HumanMessage } from '@langchain/core/messages'
04
05
export function uiMessagesFromLangchain(lcMessages: BaseMessage[]): UIMessage[] {
06
return lcMessages
07
.filter((m) => !(m.getType() === 'system')) // system 不给前端看
08
.map((m, i) => ({
09
id: `msg_${i}`,
10
role: m.getType() === 'human' ? 'user' : 'assistant',
11
parts: [
12
{
13
type: 'text',
14
text: typeof m.content === 'string' ? m.content : JSON.stringify(m.content),
15
},
16
],
17
}))
18
}

6. 完整代码骨架:AI 伴侣主管线

把上面的桥接全拼起来,看完整的主管线结构。

6.1 LangGraph StateGraph(管线层)

companion-graph.ts
01
import { StateGraph, Annotation } from '@langchain/langgraph'
02
import type { BaseMessage } from '@langchain/core/messages'
03
import { generateObject } from 'ai'
04
import { z } from 'zod'
05
06
const CompanionState = Annotation.Root({
07
sessionId: Annotation<string>,
08
messages: Annotation<BaseMessage[]>({ reducer: (a, b) => [...a, ...b] }),
09
emotion: Annotation<{ primary: string; intensity: number } | undefined>,
10
memories: Annotation<Memory[]>({ default: () => [] }),
11
systemPrompt: Annotation<string>,
12
})
13
14
export function createCompanionGraph(env: Env) {
15
const workflow = new StateGraph(CompanionState)
16
// 节点 1:加载上下文
17
.addNode('loadContext', async (state) => {
18
const profile = await loadUserProfile(env.DB, state.sessionId)
19
return { /* 更新 state */ }
20
})
21
22
// 节点 2:情绪分类(用 AI SDK generateObject)
23
.addNode('emotionClassifier', async (state) => {
24
const lastUser = state.messages.at(-1)
25
const { object } = await generateObject({
26
model: models.structured,
27
schema: z.object({
28
primary: z.enum(['happy', 'sad', 'angry', 'calm', 'neutral']),
29
intensity: z.number().min(0).max(1),
30
}),
31
prompt: `分类这段话的情绪:${lastUser?.content}`,
32
})
33
return { emotion: object }
34
})
35
36
// 节点 3:记忆检索(用 LangChain Retriever)
37
.addNode('memoryRetrieval', async (state) => {
38
const query = String(state.messages.at(-1)?.content ?? '')
39
const retriever = buildLangchainRetriever(env)
40
const docs = await retriever.invoke(query)
41
return { memories: docs.map(d => ({ content: d.pageContent, ...d.metadata })) }
42
})
43
44
// 节点 4:构造 system prompt
45
.addNode('buildPrompt', async (state) => {
46
const prompt = buildCompanionPrompt({ /* ... */ })
47
return { systemPrompt: prompt }
48
})
49
50
// 节点 5:LLM 生成(这个节点特别,交给 AI SDK 流式处理)
51
.addNode('llm', async (state) => {
52
// 这个节点只是占位,真正的 streamText 发生在桥接层
53
// 节点返回空更新
54
return {}
55
})
56
57
.addEdge('__start__', 'loadContext')
58
.addEdge('loadContext', 'emotionClassifier')
59
.addEdge('emotionClassifier', 'memoryRetrieval')
60
.addEdge('memoryRetrieval', 'buildPrompt')
61
.addEdge('buildPrompt', 'llm')
62
.addEdge('llm', '__end__')
63
64
return workflow.compile()
65
}

6.2 桥接层:把 LangGraph + AI SDK 合流

bridge-main.ts
01
export async function runCompanionPipeline(
02
env: Env,
03
sessionId: string,
04
uiMessages: UIMessage[],
05
signal?: AbortSignal,
06
) {
07
const graph = createCompanionGraph(env)
08
09
const uiStream = createUIMessageStream({
10
execute: async ({ writer }) => {
11
let systemPrompt = ''
12
13
// 跑到 llm 节点前的所有节点
14
for await (const [event, metadata] of graph.stream(
15
{
16
sessionId,
17
messages: langchainMessagesFromUI(uiMessages),
18
},
19
{ streamMode: 'values', signal },
20
)) {
21
const node = metadata.langgraph_node
22
23
if (node === 'emotionClassifier' && event.emotion) {
24
writer.write({
25
type: 'data-emotion',
26
data: event.emotion,
27
})
28
}
29
30
if (node === 'memoryRetrieval' && event.memories) {
31
writer.write({
32
type: 'data-memories-used',
33
data: { count: event.memories.length },
34
})
35
}
36
37
if (node === 'buildPrompt' && event.systemPrompt) {
38
systemPrompt = event.systemPrompt
39
}
40
41
if (node === 'llm') {
42
// 此时 graph 跑到 llm 节点,让 AI SDK 流式接管
43
const result = streamText({
44
model: buildCompanionModel(env, sessionId), // 带中间件
45
system: systemPrompt,
46
messages: convertToModelMessages(uiMessages),
47
tools: {
48
searchMemory: searchMemoryTool(env, sessionId),
49
updateEmotion: updateEmotionTool(env, sessionId),
50
},
51
stopWhen: stepCountIs(5),
52
abortSignal: signal,
53
54
onFinish: ({ text, usage }) => {
55
// 异步写回 D1 / Vectorize
56
writeBackAsync(env, sessionId, text, usage)
57
},
58
})
59
60
// 合并 AI SDK 的流到 UIMessageStream
61
writer.merge(result.toUIMessageStream())
62
}
63
}
64
},
65
})
66
67
return createUIMessageStreamResponse({ stream: uiStream })
68
}

6.3 HTTP 入口(Hono)

route.ts
1
import { Hono } from 'hono'
2
3
app.post('/chat', async (c) => {
4
const { messages, sessionId } = await c.req.json()
5
return runCompanionPipeline(c.env, sessionId, messages, c.req.raw.signal)
6
})

6.4 前端(useChat)

前端代码不变。就是 聊天 UI 标准实现useChat 标准用法,前端根本不需要知道后端是怎么组合 LangGraph 和 AI SDK 的。

7. 这样做值不值?

协同模式相比「纯 AI SDK」多了一些复杂度:

  • 两套消息模型转换
  • 桥接层代码(大约 150 行)
  • 学习成本(要懂 LangGraph)

换来的收益也不小:

  • 管线可视化和可调试:LangGraph Studio / LangSmith 里能看整个图的每一步
  • 节点级替换:情绪节点换个实现,其他代码不动
  • HITL 就位:LangGraph 的 interrupt 天然支持审批断点
  • 时间旅行:checkpointer 可以回到任意历史状态重跑
  • 多 Agent 扩展:想加个 supervisor、加个 swarm,LangGraph 生态都是现成的

判断要不要用协同模式:

  • 管线节点少于 3 个,不需要 HITL 或 checkpointer,纯 AI SDK 够用
  • 管线节点超过 3 个,或者需要上面任一特性,就值得用协同模式

AI 伴侣项目管线超过 5 节点,未来要加 HITL(情绪低落时的人工干预),还需要回放 debug(用户投诉某句回复时)。这种情况下协同模式的收益明显大于复杂度。

8. 其他组合场景

除了 AI 伴侣主管线,下面几个场景也适合协同。

纯 LangChain RAG + AI SDK 前端

大型知识库问答产品,后端主要是 LangChain 的 RAG 管线(Document Loader、多路 Retriever + Re-ranker、Memory + 摘要)。前端还是 useChat + UI Parts 享受流式体验。桥接层只需要把最终的 LangChain LLM 调用换成 AI SDK streamText

多 Agent LangGraph Swarm + AI SDK UI

Agent 协作型产品(写代码、写报告、做研究),LangGraph Swarm 负责 agent 之间的 handoff。前端用 UI Parts 渲染每个 agent 的贡献、handoff 的过程、最终 supervisor 的决策。

AI SDK 主管线 + LangChain 工具箱

最轻量的组合:主管线就是 streamText + tools,但有一两个工具(RAG、摘要)用 LangChain 实现。不需要桥接层,只是在 tool 的 execute 里 import LangChain。AI 伴侣的初期 MVP 其实也可以走这种组合,随着能力复杂再升级到完整协同模式。

9. 小结

  • 协同模式 C 在 AI 伴侣项目里是最优解:LangGraph 做管线,AI SDK 做胶水和前端
  • 桥接一:LangGraph event → UIMessageStream,用 createUIMessageStream + 按节点分发 part
  • 桥接二:AI SDK tool.execute 内部直接 import 用 LangChain 的 Retriever / Memory / VectorStore
  • 桥接三:UIMessage ↔ LangChain BaseMessage,双向转换函数
  • 完整骨架:LangGraph 节点到 llm 前是 pipeline,llm 节点交给 AI SDK 的 streamText 流式处理,结果并回 UIMessageStream
  • 取舍:3 节点以下不用协同模式;超过 3 节点或要 HITL / checkpointer,协同模式值得

下一篇是本章实战——用 AI SDK 重构端到端 AI Chat。对照 实战:端到端 AI Chat 的手写版本,把本章所有内容落成一个可运行的完整 demo。