大模型学习笔记:用 LangChain + LangGraph 重写机票预订对话系统
前言
在上一篇 基于 OpenAI API 的机票预订对话系统实现 中,我用纯 OpenAI SDK 实现了一套多轮对话 + 工具调用的机票预订系统。那篇笔记覆盖了 XML 标签、SSE 协议、流式解析等"手写"方案。
这次我用 LangChain + LangGraph 把同一个业务重写了一遍,感受框架带来的变化。本文重点说差异和 LangGraph 相关的 API 用法。
两种方案的直观对比
| 维度 | 旧方案(OpenAI SDK 原生) | 新方案(LangChain + LangGraph) |
|---|---|---|
| 工具调用 | 提示词里约定 SSE event: tool-call 格式,手动 JSON.parse 参数 |
model.bindTools([tool]),模型原生返回结构化 tool_calls |
| 向用户提问 | SSE event: question,eventsource-parser 解析后 readlineSync.question |
LangGraph interrupt() 暂停图执行,Command({ resume }) 恢复 |
| 状态管理 | vue ref() + watchEffect 手写 while 循环 |
LangGraph StateGraph + MessagesAnnotation + MemorySaver |
| 流程控制 | 手动解析事件类型,if/else 分流 |
addConditionalEdges 路由函数,声明式定义流转 |
| 提示词 | 字符串拼接,ref.value.push() 逐条管理 |
ChatPromptTemplate.fromMessages + placeholder 变量注入 |
一句话总结:旧方案是"协议 + 解析器 + 手写循环",新方案是"声明式图 + 框架内建能力"。
技术栈
json
{
"@langchain/openai": "^1.2.0",
"@langchain/core": "^1.1.8",
"@langchain/langgraph": "^1.0.7"
}
整体架构:StateGraph
LangGraph 的核心概念是把 AI 对话流程抽象成一个有向图,每个节点是一个处理步骤,边定义节点间的流转关系。
先看图结构,这个是 LangGraph 编译后自动生成的 Mermaid 图:
- 实线边(
--->):无条件流转 - 虚线边(
-.->):条件路由,由路由函数决定走向 __start__→agent:图入口,消息自动进入 agent 节点agent→tools:模型决定调用工具agent→askUser:模型输出文本,需要向用户提问agent→__end__:模型输出结束标记,对话结束tools→agent:工具结果回灌给 agent 继续推理askUser→agent:用户回答回灌给 agent 继续推理
API 详解
1. ChatOpenAI + bindTools
typescript
import { ChatOpenAI } from '@langchain/openai'
const model = new ChatOpenAI({
apiKey: process.env.XIAOMI_TOKEN_PLAN_API_KEY,
model: process.env.XIAOMI_TOKEN_PLAN_MODEL,
temperature: 0.7,
configuration: {
baseURL: process.env.XIAOMI_TOKEN_PLAN_BASE_URL, // 兼容任意 OpenAI API 的服务
},
})
// bindTools 让模型知道有哪些工具可用
// 模型会在回复中通过原生 function calling 返回结构化的 tool_calls
const modelWithTools = model.bindTools([orderSearchTool])
和旧方案的关键区别:旧方案需要模型在文本里输出 event: tool-call\ndata: {"tool":"xxx","args":{...}},然后代码里手动 JSON.parse。bindTools 后模型直接在 AIMessage.tool_calls 字段里返回结构化数据,不需要任何文本解析。
实际返回示例:
typescript
// modelWithTools.invoke("帮我查明天北京到上海的机票") 后:
{
tool_calls: [{
id: 'call_d5a09efc0de7469f9f68b16c',
name: '订单价格查询',
args: { from: '北京', to: '上海', date: '2026-06-25', level: '经济舱' },
type: 'tool_call'
}],
content: ''
}
2. MessagesAnnotation
typescript
import { MessagesAnnotation } from '@langchain/langgraph'
const State = MessagesAnnotation
MessagesAnnotation 是 LangGraph 预定义的 State Schema,只有 messages 一个字段。它内置了一个 reducer(消息追加器):新消息自动 append 到列表,不会覆盖已有消息。
这替代了旧方案中手动 ref.value.push(...) 的操作——图执行过程中每个节点返回 { messages: [newMessage] },框架自动追加。
3. 节点定义
typescript
// agent 节点:渲染提示词 → 调模型
async function agentNode(state) {
const formatted = await promptTemplate.invoke({
current_date: now.toLocaleDateString(),
current_time: now.toLocaleTimeString(),
messages: state.messages, // 注入对话历史
})
const response = await modelWithTools.invoke(formatted)
return { messages: [response] } // 返回新消息,reducer 自动追加
}
typescript
// askUser 节点:interrupt 暂停,等待人类输入
async function askUserNode(state) {
const lastMsg = state.messages.at(-1)
const answer = interrupt({ question: lastMsg.content }) // 关键!
return { messages: [new HumanMessage(answer)] }
}
typescript
// tools 节点:LangGraph 内置的 ToolNode,自动执行 tool_calls
const toolNode = new ToolNode([orderSearchTool])
4. 路由函数
typescript
function routeAgentOutput(state): string {
const last = state.messages.at(-1)
if (last.content.includes('__END__')) return END // 模型说结束
if (last.tool_calls?.length) return 'tools' // 要调工具
if (last.content.trim()) return 'askUser' // 有文本,问用户
return END
}
路由函数是条件边的决策逻辑,替代了旧方案里的 if (event.event === 'question') / if (event.event === 'tool-call') / if (event.event === 'finish') 那一堆事件分发代码。
5. 图组装
typescript
const graph = new StateGraph(State)
.addNode('agent', agentNode)
.addNode('tools', toolNode)
.addNode('askUser', askUserNode)
.addEdge(START, 'agent') // 入口
.addConditionalEdges('agent', routeAgentOutput, // 条件路由
['tools', 'askUser', END])
.addEdge('tools', 'agent') // 工具 → 继续推理
.addEdge('askUser', 'agent') // 用户回答 → 继续推理
const compiledGraph = graph.compile({ checkpointer: new MemorySaver() })
addConditionalEdges 的第三个参数是声明的所有可能的返回值,LangGraph 会在编译时验证——如果你的路由函数返回了一个没声明的值,直接报错。这比旧方案的隐式事件类型安全得多。
6. interrupt:人工中断与恢复
这是 LangGraph 最核心的 human-in-the-loop 机制:
typescript
// 第一次 invoke:图跑到 interrupt() 时暂停,控制权回到调用方
let result = await compiledGraph.invoke(
{ messages: [new HumanMessage('我要买机票')] },
{ configurable: { thread_id: 'order-fly-tick' } },
)
// result.__interrupt__ 包含 interrupt() 传入的数据
console.log(result.__interrupt__)
// [{ value: { question: '请问您想从哪个城市出发?' } }]
// 用户输入后,用 Command resume 恢复执行
result = await compiledGraph.invoke(
new Command({ resume: '从北京出发' }),
{ configurable: { thread_id: 'order-fly-tick' } },
)
工作原理:
interrupt()内部抛出一个特殊异常MemorySaver(checkpointer)捕获异常,将当前图状态持久化- 控制权回到调用方,返回
__interrupt__字段 - 调用方收集用户输入后,用
Command({ resume })恢复 - 图从
interrupt()断点继续执行,interrupt()的返回值就是resume传入的值
这替代了旧方案中 readlineSync.question() 嵌套在 while 循环里的模式。新方案里图的执行和用户交互是完全解耦的。
7. ChatPromptTemplate
typescript
import { ChatPromptTemplate } from '@langchain/core/prompts'
import fs from 'fs'
import path from 'path'
// 从 Markdown 文件读取系统提示词
const systemPrompt = fs.readFileSync('prompt/oder_fly.tick_prompt.md', 'utf-8')
const promptTemplate = ChatPromptTemplate.fromMessages([
['system', systemPrompt],
['placeholder', '{messages}'], // 运行时注入对话历史
])
// 使用时
const formatted = await promptTemplate.invoke({
current_date: now.toLocaleDateString(),
current_time: now.toLocaleTimeString(),
messages: state.messages,
})
关键点:
- 单一来源:系统提示词写在
.md文件里(人读 = 机用),代码不再有硬编码的提示词字符串 placeholder:{messages}占位符会在invoke时被运行时的消息列表替换- 变量注入:
{current_date}等变量在invoke时动态传入
替代了旧方案里 ref<ChatCompletionMessageParam[]>([...]) 中硬编码大段字符串的方式。
8. MemorySaver
typescript
import { MemorySaver } from '@langchain/langgraph'
const checkpointer = new MemorySaver()
const compiledGraph = graph.compile({ checkpointer })
MemorySaver 是内存级别的状态持久化器,配合 thread_id 实现多轮对话的状态隔离。它是 interrupt 能工作的前提——没有 checkpointer,interrupt 无法暂停和恢复。
生产环境可以替换成 SqliteSaver 或其他持久化 checkpointer。
结束判断:END 标记
LangGraph 的 END 是内置终点节点,不需要 addNode。关键问题是怎么让路由函数知道该结束了。
方案是在提示词里约定模型输出 __END__ 标记:
markdown
<!-- 提示词中 -->
- 用户确认后,**回复 `__END__`** 作为对话结束的标记
路由函数检测到 __END__ 后返回 END,图就停止了:
typescript
if (last.content.includes('__END__')) return END
agentNode 里在返回前清理掉标记,避免日志里出现:
typescript
if (response.content.includes('__END__')) {
response.content = response.content.replace(/__END__/g, '').trim()
}
这比旧方案的 <finish> 标签更干净——因为 __END__ 是被代码读取的控制信号,而不是需要正则解析的业务内容。
完整执行流程
以"我要买明天从北京到上海的机票"为例:
注意 agent → tools → agent 这个子循环是框架自动处理的,不需要任何手写代码。旧方案里对应的逻辑是 if (event.event === 'tool-call') { JSON.parse(); tool.invoke(); push result }。
核心代码文件
| 文件 | 作用 |
|---|---|
provider/langchain-openai.ts |
ChatOpenAI 初始化配置 |
prompt/oder_fly.tick_prompt.md |
系统提示词(人读 + 机用的单一来源) |
prompt/order_fly_tick_langchain_prompt.ts |
ChatPromptTemplate 模板定义 |
tools/order-search.ts |
LangChain tool 定义(两个方案共用) |
demo/order-fly-tick-langgraph.ts |
LangGraph StateGraph 完整实现 |
开发过程中的踩坑
1. MessagesValue 导入路径
LangGraph 文档示例里用了 StateSchema + MessagesValue,但实际安装的 @langchain/langgraph@1.x 导出的是 MessagesAnnotation。这是新旧 API 的差异:
typescript
// ❌ 文档里写的不一定能用
import { StateSchema, MessagesValue } from '@langchain/langgraph'
// ✅ 实际可用的
import { MessagesAnnotation } from '@langchain/langgraph'
const State = MessagesAnnotation
2. 路由函数返回值必须在声明列表里
addConditionalEdges 的第三个参数声明了所有合法的返回值。如果路由函数返回了一个没声明的值,编译时会报错:
Error: Found edge ending at unknown node `askUser`
这是因为测试时忘了 .addNode('askUser', ...) 但路由函数返回了 'askUser'。LangGraph 的编译时验证比旧方案的隐式事件类型匹配严格得多——类型不匹配直接报错,而不是运行时默默忽略。
3. 模型不支持 function calling
bindTools 要求模型支持原生 function calling。如果模型不支持,AIMessage.tool_calls 会是空数组。我在开发时用小米 Token Plan(mimo-v2.5)实测,确认支持后才开始写后续逻辑。如果不确定,先用最小代码验证 bindTools 是否返回 tool_calls:
typescript
const res = await modelWithTools.invoke('帮我查北京到上海的机票')
console.log(res.tool_calls) // 如果是空数组,说明模型不支持
4. 提示词里"无语义标记"依然不可靠
虽然用了 __END__ 作为结束标记(比旧方案的 \n\n 好一些,因为它是语义化的字符串),但仍然可能出现模型忘记输出的问题。解决方案是在提示词里强调默认值规则,减少模型的追问轮次,让对话更容易收敛到确认环节。
新方案的优点
- 工具调用零解析:
bindTools返回结构化tool_calls,不需要JSON.parse、不需要 SSE 协议、不需要 eventsource-parser - 流程声明式:图结构一目了然,
addConditionalEdges替代隐式事件分发 - 交互与执行解耦:
interrupt让图的执行和用户输入完全分离,不再需要嵌套while循环 - 编译时验证:节点、边的连接关系在
compile()时检查,错误提前暴露 - 状态自动管理:
MessagesAnnotation的 reducer 自动追加消息,不用手动 push - 可观测性:
graph.getGraph().drawMermaid()直接生成图结构可视化
总结
用 LangChain + LangGraph 重写后,代码量从 ~380 行降到 ~230 行(order-fly-tick-langgraph.ts),同时去掉了 StreamXMLParser、eventsource-parser、renderToolsForPrompt 等自研组件的依赖。
但更重要的是思维方式的转变:从"设计协议 → 写解析器 → 手写循环"变成了"声明节点 → 定义边 → 框架驱动"。后者更抽象,但更适合复杂的 Agent 场景。
项目地址: ai-learn
相关代码文件: