大模型学习笔记:用 LangChain + LangGraph 重写机票预订对话系统

2026年06月24日6 次阅读0 人喜欢
AILLMTypeScriptNode.jsLangChainLangGraph对话系统function-calling
所属合集

前言

在上一篇 基于 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: questioneventsource-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 图:

graph TD; __start__ --> agent; askUser --> agent; tools --> agent; agent -.-> tools; agent -.-> askUser; agent -.-> __end__;
  • 实线边--->):无条件流转
  • 虚线边-.->):条件路由,由路由函数决定走向
  • __start__agent:图入口,消息自动进入 agent 节点
  • agenttools:模型决定调用工具
  • agentaskUser:模型输出文本,需要向用户提问
  • agent__end__:模型输出结束标记,对话结束
  • toolsagent:工具结果回灌给 agent 继续推理
  • askUseragent:用户回答回灌给 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.parsebindTools 后模型直接在 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' } },
)

工作原理

  1. interrupt() 内部抛出一个特殊异常
  2. MemorySaver(checkpointer)捕获异常,将当前图状态持久化
  3. 控制权回到调用方,返回 __interrupt__ 字段
  4. 调用方收集用户输入后,用 Command({ resume }) 恢复
  5. 图从 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__被代码读取的控制信号,而不是需要正则解析的业务内容。

完整执行流程

以"我要买明天从北京到上海的机票"为例:

sequenceDiagram participant 用户 participant 主循环 participant agent participant tools participant askUser 用户->>主循环: "我要买明天从北京到上海的机票" 主循环->>agent: HumanMessage agent->>tools: tool_calls: [订单价格查询] tools-->>agent: ToolMessage: "价格158元" agent->>askUser: "订单确认表格..." askUser-->>主循环: interrupt({ question: "..." }) 主循环->>用户: 显示确认问题 用户->>主循环: "确认" 主循环->>askUser: Command({ resume: "确认" }) askUser-->>agent: HumanMessage: "确认" agent->>主循环: "__END__" → 路由到 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 好一些,因为它是语义化的字符串),但仍然可能出现模型忘记输出的问题。解决方案是在提示词里强调默认值规则,减少模型的追问轮次,让对话更容易收敛到确认环节。

新方案的优点

  1. 工具调用零解析bindTools 返回结构化 tool_calls,不需要 JSON.parse、不需要 SSE 协议、不需要 eventsource-parser
  2. 流程声明式:图结构一目了然,addConditionalEdges 替代隐式事件分发
  3. 交互与执行解耦interrupt 让图的执行和用户输入完全分离,不再需要嵌套 while 循环
  4. 编译时验证:节点、边的连接关系在 compile() 时检查,错误提前暴露
  5. 状态自动管理MessagesAnnotation 的 reducer 自动追加消息,不用手动 push
  6. 可观测性graph.getGraph().drawMermaid() 直接生成图结构可视化

总结

用 LangChain + LangGraph 重写后,代码量从 ~380 行降到 ~230 行(order-fly-tick-langgraph.ts),同时去掉了 StreamXMLParsereventsource-parserrenderToolsForPrompt 等自研组件的依赖。

但更重要的是思维方式的转变:从"设计协议 → 写解析器 → 手写循环"变成了"声明节点 → 定义边 → 框架驱动"。后者更抽象,但更适合复杂的 Agent 场景。


项目地址: ai-learn

相关代码文件:

加载评论中...