课程:AI 全栈开发实战 — 基于 LangChain.js + Vue3 + Node.js 实战 Demo:极速购客服系统 — 订单查询 + 物流跟踪 Agent
代码
2.1 本章目标
完成本章学习后,你将能够:
- 理解 Function Calling 的工作原理,知道 LLM 是如何"决定"调用工具的
- 掌握 LangChain.js 中 Tool 的定义方式
- 理解 ReAct Agent 的思维循环:Thought → Action → Observation → Answer
- 构建一个可以查询订单、跟踪物流的极速购客服 Agent
- 在 Vue3 前端实时展示 Agent 的中间推理步骤
2.2 什么是 Function Calling
第一章的 Chain 是固定流程:Prompt 进去,文本出来。但很多场景需要 LLM 去查数据库、调接口、执行计算,这时候就需要 Function Calling。
Function Calling 让 LLM 在回答问题时,可以输出"我需要调用某个函数,参数是 xxx",由外部代码执行后把结果再传回给 LLM,LLM 基于结果给出最终回答。
整个过程 LLM 本身不执行任何代码,它只负责决策:要不要调用工具、调哪个、传什么参数。
举个例子,用户问"我的订单 ORD-001 发货了吗":
- LLM 收到问题,判断需要查询订单信息
- LLM 输出:调用
getOrderInfo,参数{ orderId: "ORD-001" } - 外部代码执行查询,拿到订单数据
- 把查询结果作为 Observation 传回 LLM
- LLM 基于结果回答用户
这个循环可以执行多轮,直到 LLM 认为信息足够,给出最终答案。
2.3 ReAct Agent 思维模式
LangChain.js 默认使用 ReAct(Reasoning + Acting)模式。每一轮 LLM 的输出结构如下:
Thought: 我需要查询订单 ORD-001 的状态
Action: getOrderInfo
Action Input: { "orderId": "ORD-001" }
Observation: { "orderId": "ORD-001", "status": "已发货", "carrier": "顺丰", "trackingNo": "SF1234567890" }
Thought: 已经拿到订单信息,可以回答用户了
Final Answer: 您的订单 ORD-001 已于昨日发货,快递公司顺丰,单号 SF1234567890,预计明天送达。
这个模式的好处是每一步推理都是透明的,可以在前端把思考过程展示给用户,也方便调试。
2.4 定义 Tool
LangChain.js 中用 tool() 函数定义工具,需要提供名称、描述、参数 schema、执行函数四个要素。描述写得好不好直接影响 LLM 能不能正确选择工具。
2.4.1 安装依赖
cd server
pnpm add zod
zod 用于定义工具的参数 schema,LangChain.js 用它生成传给 LLM 的函数描述。
2.4.2 模拟数据
实际项目会查数据库,这里先用静态数据模拟:
// server/src/data/mock.js
export const orders = {
'ORD-001': {
orderId: 'ORD-001',
userId: 'U-100',
status: '已发货',
createTime: '2025-03-20 10:30:00',
amount: 299.00,
items: [{ name: 'iPhone 手机壳', qty: 2, price: 49.5 }, { name: '钢化膜', qty: 1, price: 200 }],
carrier: '顺丰速运',
trackingNo: 'SF1234567890',
},
'ORD-002': {
orderId: 'ORD-002',
userId: 'U-100',
status: '待付款',
createTime: '2025-03-22 15:00:00',
amount: 899.00,
items: [{ name: '蓝牙耳机', qty: 1, price: 899 }],
carrier: null,
trackingNo: null,
},
'ORD-003': {
orderId: 'ORD-003',
userId: 'U-101',
status: '已完成',
createTime: '2025-03-18 09:00:00',
amount: 1299.00,
items: [{ name: '机械键盘', qty: 1, price: 1299 }],
carrier: '京东物流',
trackingNo: 'JD9876543210',
},
};
export const logistics = {
'SF1234567890': [
{ time: '2025-03-21 08:00', location: '上海转运中心', desc: '快件已发出' },
{ time: '2025-03-21 18:30', location: '杭州分拨中心', desc: '快件到达' },
{ time: '2025-03-22 09:00', location: '杭州西湖营业点', desc: '派件中,预计今日送达' },
],
'JD9876543210': [
{ time: '2025-03-18 14:00', location: '北京仓库', desc: '已揽收' },
{ time: '2025-03-19 10:00', location: '北京转运中心', desc: '运输中' },
{ time: '2025-03-20 08:00', location: '用户门口', desc: '已签收' },
],
};
2.4.3 定义订单查询工具
// server/src/tools/order-tools.js
import { tool } from '@langchain/core/tools';
import { z } from 'zod';
import { orders, logistics } from '../data/mock.js';
// 工具1:查询订单信息
export const getOrderInfoTool = tool(
async ({ orderId }) => {
const order = orders[orderId];
if (!order) return JSON.stringify({ error: `订单 ${orderId} 不存在` });
return JSON.stringify(order);
},
{
name: 'getOrderInfo',
// 描述要清晰,告诉 LLM 什么时候该用这个工具
description: '根据订单号查询订单详情,包括订单状态、商品列表、金额、快递信息。当用户询问订单状态、订单内容时调用。',
schema: z.object({
orderId: z.string().describe('订单号,格式为 ORD-xxx'),
}),
}
);
// 工具2:查询物流轨迹
export const getLogisticsTool = tool(
async ({ trackingNo }) => {
const records = logistics[trackingNo];
if (!records) return JSON.stringify({ error: `快递单号 ${trackingNo} 暂无物流信息` });
return JSON.stringify({ trackingNo, records });
},
{
name: 'getLogisticsInfo',
description: '根据快递单号查询物流轨迹,包括各节点时间、地点、状态。当用户询问快递到哪了、物流状态时调用。',
schema: z.object({
trackingNo: z.string().describe('快递单号'),
}),
}
);
// 工具3:查询用户所有订单
export const getUserOrdersTool = tool(
async ({ userId }) => {
const userOrders = Object.values(orders).filter(o => o.userId === userId);
if (userOrders.length === 0) return JSON.stringify({ error: `用户 ${userId} 暂无订单` });
// 只返回摘要,避免 token 过多
const summary = userOrders.map(o => ({
orderId: o.orderId,
status: o.status,
amount: o.amount,
createTime: o.createTime,
}));
return JSON.stringify(summary);
},
{
name: 'getUserOrders',
description: '根据用户 ID 查询该用户的所有订单列表。当用户询问"我有哪些订单"、"最近的订单"时调用。',
schema: z.object({
userId: z.string().describe('用户 ID,格式为 U-xxx'),
}),
}
);
export const allTools = [getOrderInfoTool, getLogisticsTool, getUserOrdersTool];
2.5 创建 Agent
LangChain.js 提供 createReactAgent 创建 ReAct Agent,再用 AgentExecutor 负责循环执行。
// server/src/agents/customer-agent.js
import { createReactAgent } from 'langchain/agents';
import { AgentExecutor } from 'langchain/agents';
import { ChatPromptTemplate } from '@langchain/core/prompts';
import { createModel } from '../models/deepseek.js';
import { allTools } from '../tools/order-tools.js';
// Agent 专用 Prompt
// 必须包含 {tools}、{tool_names}、{agent_scratchpad} 三个占位符
// {agent_scratchpad} 是 LangChain 存放中间推理步骤的地方
const agentPrompt = ChatPromptTemplate.fromMessages([
[
'system',
`你是极速购电商平台的智能客服助手小购。
你可以使用以下工具查询信息:
{tools}
回答规则:
1. 需要查询数据时,先调用对应工具获取真实数据,不要猜测或编造
2. 语气友好,称呼用户为"亲"
3. 拿到数据后用自然语言组织回答,不要直接粘贴 JSON
4. 如果用户没有提供订单号但需要查询,先询问订单号
工具名称:{tool_names}
当前时间:{current_time}`,
],
['placeholder', '{chat_history}'],
['human', '{input}'],
['placeholder', '{agent_scratchpad}'],
]);
const model = createModel({ temperature: 0 }); // Agent 场景用 0,保证工具调用准确
export const createCustomerAgent = () => {
const agent = createReactAgent({
llm: model,
tools: allTools,
prompt: agentPrompt,
});
return new AgentExecutor({
agent,
tools: allTools,
maxIterations: 5, // 最多循环 5 次,防止死循环
returnIntermediateSteps: true, // 返回中间推理步骤,前端可以展示
verbose: true, // 开发阶段打开,可以在终端看到完整推理过程
});
};
2.6 Agent 路由接口
Agent 的接口和普通 Chat 不同:需要返回中间步骤,前端才能展示思考过程。这里仍然用 SSE,分别推送思考步骤和最终答案。
// server/src/routes/agent.js
import express from 'express';
import { createCustomerAgent } from '../agents/customer-agent.js';
import { formatHistory } from '../chains/basic-chat.js';
const router = express.Router();
router.post('/stream', async (req, res) => {
const { message, history = [], userId = 'U-100' } = req.body;
if (!message) return res.status(400).json({ error: 'message 不能为空' });
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
const send = (type, data) =>
res.write(`data: ${JSON.stringify({ type, ...data })}\n\n`);
try {
const executor = createCustomerAgent();
const result = await executor.invoke({
input: message,
chat_history: formatHistory(history),
current_time: new Date().toLocaleString('zh-CN'),
userId,
});
// 推送中间步骤
if (result.intermediateSteps?.length) {
for (const step of result.intermediateSteps) {
send('step', {
tool: step.action.tool,
toolInput: step.action.toolInput,
observation: step.observation,
});
}
}
// 推送最终回答
send('answer', { content: result.output });
send('done', {});
res.end();
} catch (err) {
console.error('[Agent Error]', err);
send('error', { content: '处理请求时出错,请重试' });
res.end();
}
});
export default router;
在 server/src/index.js 中注册路由:
import agentRouter from './routes/agent.js';
app.use('/api/agent', agentRouter);
2.7 前端展示 Agent 推理过程
Agent 的核心体验是让用户看到 AI 在做什么,而不是等待黑盒输出。这里设计一个带有"思考步骤"展示区的对话界面。
2.7.1 useAgent Composable
// client/src/composables/useAgent.js
import { ref, nextTick } from 'vue';
const API_BASE = 'http://localhost:3000/api';
export function useAgent() {
const messages = ref([]);
const loading = ref(false);
const steps = ref([]); // 当前轮次的中间步骤
const error = ref('');
const sendMessage = async (userInput, scrollCallback) => {
if (!userInput.trim() || loading.value) return;
error.value = '';
steps.value = [];
messages.value.push({ role: 'user', content: userInput });
scrollCallback?.();
loading.value = true;
// 占位,流式过程中更新
const assistantIndex = messages.value.length;
messages.value.push({ role: 'assistant', content: '', thinking: true });
try {
const history = messages.value
.slice(0, -1)
.slice(-10)
.filter(m => !m.thinking)
.map(({ role, content }) => ({ role, content }));
const response = await fetch(`${API_BASE}/agent/stream`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: userInput, history }),
});
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
while (true) {
const { done, value } = await reader.read();
if (done) break;
const lines = decoder
.decode(value, { stream: true })
.split('\n')
.filter(l => l.startsWith('data: '));
for (const line of lines) {
try {
const parsed = JSON.parse(line.slice(6));
if (parsed.type === 'step') {
steps.value.push({
tool: parsed.tool,
toolInput: parsed.toolInput,
observation: parsed.observation,
});
await nextTick();
scrollCallback?.();
}
if (parsed.type === 'answer') {
messages.value[assistantIndex] = {
role: 'assistant',
content: parsed.content,
steps: [...steps.value],
};
await nextTick();
scrollCallback?.();
}
if (parsed.type === 'done') {
steps.value = [];
}
if (parsed.type === 'error') {
messages.value[assistantIndex] = {
role: 'assistant',
content: parsed.content,
};
}
} catch {}
}
}
} catch (err) {
error.value = `请求失败:${err.message}`;
messages.value.pop();
} finally {
loading.value = false;
}
};
const clearMessages = () => {
messages.value = [];
steps.value = [];
error.value = '';
};
return { messages, loading, steps, error, sendMessage, clearMessages };
}
2.7.2 AgentView 组件
<!-- client/src/views/AgentView.vue -->
<template>
<div class="agent-page">
<header class="chat-header">
<div class="header-left">
<div class="avatar">购</div>
<div>
<h1>极速购智能客服(Agent 模式)</h1>
<span :class="['status', { active: !loading }]">
{{ loading ? '思考中...' : '在线' }}
</span>
</div>
</div>
<button @click="clearMessages">清空对话</button>
</header>
<main class="messages-wrap" ref="messagesRef">
<div v-if="messages.length === 0" class="welcome">
<p>您好,我是极速购智能客服小购。</p>
<p>我可以帮您查询订单状态和物流信息,请提供您的订单号。</p>
<div class="quick-btns">
<button v-for="q in quickQuestions" :key="q" @click="handleQuick(q)">
{{ q }}
</button>
</div>
</div>
<div
v-for="(msg, i) in messages"
:key="i"
class="message-row"
:class="msg.role"
>
<div class="avatar-sm">{{ msg.role === 'user' ? '我' : '购' }}</div>
<div class="message-content">
<!-- 工具调用步骤展示 -->
<div v-if="msg.steps && msg.steps.length" class="steps-wrap">
<div
v-for="(step, si) in msg.steps"
:key="si"
class="step-item"
>
<span class="step-label">调用工具</span>
<span class="step-tool">{{ step.tool }}</span>
<span class="step-input">{{ formatInput(step.toolInput) }}</span>
</div>
</div>
<!-- 思考中占位 -->
<div v-if="msg.thinking" class="thinking">
<span class="dot-1">.</span>
<span class="dot-2">.</span>
<span class="dot-3">.</span>
</div>
<!-- 最终回答 -->
<div v-else class="bubble">{{ msg.content }}</div>
</div>
</div>
<!-- 当前轮次实时步骤 -->
<div v-if="loading && steps.length" class="message-row assistant">
<div class="avatar-sm">购</div>
<div class="message-content">
<div class="steps-wrap">
<div v-for="(step, si) in steps" :key="si" class="step-item">
<span class="step-label">调用工具</span>
<span class="step-tool">{{ step.tool }}</span>
<span class="step-input">{{ formatInput(step.toolInput) }}</span>
</div>
</div>
<div class="thinking">
<span class="dot-1">.</span><span class="dot-2">.</span><span class="dot-3">.</span>
</div>
</div>
</div>
<div v-if="error" class="error-tip">{{ error }}</div>
</main>
<footer class="input-area">
<textarea
v-model="inputText"
placeholder="输入消息,Enter 发送,Shift+Enter 换行"
:disabled="loading"
@keydown.enter.exact.prevent="handleSend"
rows="1"
/>
<button
class="send-btn"
:disabled="loading || !inputText.trim()"
@click="handleSend"
>
{{ loading ? '思考中...' : '发送' }}
</button>
</footer>
</div>
</template>
<script setup>
import { ref, nextTick } from 'vue';
import { useAgent } from '../composables/useAgent.js';
const { messages, loading, steps, error, sendMessage, clearMessages } = useAgent();
const inputText = ref('');
const messagesRef = ref(null);
const quickQuestions = [
'查一下订单 ORD-001 的状态',
'订单 ORD-001 的快递到哪了?',
'我有哪些订单?',
];
const scrollToBottom = async () => {
await nextTick();
if (messagesRef.value)
messagesRef.value.scrollTop = messagesRef.value.scrollHeight;
};
const handleSend = async () => {
const text = inputText.value.trim();
if (!text || loading.value) return;
inputText.value = '';
await sendMessage(text, scrollToBottom);
};
const handleQuick = (q) => {
inputText.value = q;
handleSend();
};
// 工具入参格式化,只取关键字段展示
const formatInput = (input) => {
if (!input) return '';
const vals = Object.values(input);
return vals.join(' · ');
};
</script>
<style scoped>
.steps-wrap {
margin-bottom: 6px;
display: flex;
flex-direction: column;
gap: 4px;
}
.step-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
background: #f1f5f9;
border-radius: 6px;
padding: 4px 10px;
color: #475569;
}
.step-label { color: #94a3b8; }
.step-tool { font-weight: 600; color: #2563eb; }
.step-input { color: #64748b; }
.thinking {
display: flex;
gap: 2px;
padding: 10px 16px;
background: #fff;
border-radius: 12px;
font-size: 20px;
color: #94a3b8;
width: fit-content;
}
.thinking span {
animation: bounce 1.2s infinite;
}
.dot-2 { animation-delay: .2s; }
.dot-3 { animation-delay: .4s; }
@keyframes bounce {
0%, 80%, 100% { opacity: .3; transform: translateY(0); }
40% { opacity: 1; transform: translateY(-4px); }
}
</style>
2.8 Tool 描述的写法
Tool 的 description 直接决定 LLM 能不能在合适的时机选对工具,这是 Agent 开发最容易出问题的地方。
几个原则:
说清楚什么时候用,不要只描述工具做什么,更要说用户问什么类型的问题时应该调这个工具。
差的写法:
description: '查询订单'
好的写法:
description: '根据订单号查询订单详情,包括订单状态、商品列表、金额、快递信息。当用户询问订单状态、订单内容时调用。'
参数 describe 要说明格式,让 LLM 知道该传什么值:
schema: z.object({
orderId: z.string().describe('订单号,格式为 ORD-xxx,例如 ORD-001'),
})
工具之间的边界要清晰,如果有两个功能相近的工具,要在描述里明确区分,避免 LLM 选错。
2.9 调试技巧
开启 verbose 模式
在 AgentExecutor 中设置 verbose: true,终端会打印完整的 ReAct 推理过程,包括每一次 Thought、Action、Observation,调试时非常有用。
控制 maxIterations
默认值偏高,建议开发阶段设置 maxIterations: 5。如果 Agent 陷入循环(比如工具返回错误,LLM 反复重试),超过次数后会抛出异常,避免无限消耗 token。
固定 temperature 为 0
Agent 调用工具时需要精确匹配参数格式,temperature 越高越容易产生格式错误。Agent 场景统一用 temperature: 0。
工具返回值控制体积
工具返回的数据会作为 Observation 传给 LLM,消耗 token。如果数据量大(比如用户有 100 个订单),要做摘要或分页,不要把完整数据全部返回。
2.10 运行 Demo
后端已在第一章基础上新增 /api/agent 路由,前端新增 AgentView 页面。
# 后端无需重启,已自动加载新路由(nodemon 模式)
# 前端在 router/index.js 中添加路由
在 client/src/router/index.js 中注册页面:
import AgentView from '../views/AgentView.vue';
const routes = [
{ path: '/', component: ChatView },
{ path: '/agent', component: AgentView },
];
访问 http://localhost:5173/agent,输入"查一下订单 ORD-001 的状态",可以看到:
- 界面上实时出现工具调用步骤(调用 getOrderInfo · ORD-001)
- 工具执行完毕后 LLM 生成最终回答
2.11 常见问题
Q:Agent 一直循环不停,没有给出 Final Answer
工具返回了 LLM 看不懂的格式,或者工具报错。检查工具的返回值是否是合理的 JSON 字符串,错误情况也要返回结构化的错误描述,不要抛异常。
Q:LLM 没有调用工具,直接回答了
工具的 description 写得不够清晰,LLM 认为不需要查询。把 description 改得更具体,明确列出哪类用户问题应该触发这个工具。
Q:工具入参格式不对,传了 null 或者多余字段
把 temperature 调到 0,同时在 schema 的 describe 里加入格式说明和示例值。
Q:中间步骤没有显示在前端
确认 AgentExecutor 设置了 returnIntermediateSteps: true,以及后端正确推送了 type: 'step' 的 SSE 事件。
2.12 本章小结
| 知识点 | 掌握内容 |
|---|---|
| Function Calling | LLM 如何决定调用工具,调用过程的完整流程 |
| ReAct 模式 | Thought → Action → Observation → Answer 循环 |
| Tool 定义 | tool()函数,name / description / schema / 执行函数四要素 |
| Tool 描述 | 影响 LLM 工具选择的关键,写法原则 |
| AgentExecutor | createReactAgent+ AgentExecutor,maxIterations,returnIntermediateSteps |
| 前端展示 | SSE 分类型推送,实时展示中间推理步骤 |
下一章:RAG + 向量数据库,给 Agent 接入知识库,解决 LLM 知识截止日期和私有数据的问题。