返回笔记首页

第二章:Agents + Function Calling

主题配置

课程:AI 全栈开发实战 — 基于 LangChain.js + Vue3 + Node.js 实战 Demo:极速购客服系统 — 订单查询 + 物流跟踪 Agent

代码

langchain-course-ch2.zip


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 发货了吗":

  1. LLM 收到问题,判断需要查询订单信息
  2. LLM 输出:调用 getOrderInfo,参数 { orderId: "ORD-001" }
  3. 外部代码执行查询,拿到订单数据
  4. 把查询结果作为 Observation 传回 LLM
  5. LLM 基于结果回答用户

这个循环可以执行多轮,直到 LLM 认为信息足够,给出最终答案。


2.3 ReAct Agent 思维模式

LangChain.js 默认使用 ReAct(Reasoning + Acting)模式。每一轮 LLM 的输出结构如下:

plain
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 安装依赖

bash
cd server
pnpm add zod

zod 用于定义工具的参数 schema,LangChain.js 用它生成传给 LLM 的函数描述。

2.4.2 模拟数据

实际项目会查数据库,这里先用静态数据模拟:

javascript
// 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 定义订单查询工具

javascript
// 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 负责循环执行。

javascript
// 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,分别推送思考步骤和最终答案。

javascript
// 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 中注册路由:

javascript
import agentRouter from './routes/agent.js';
app.use('/api/agent', agentRouter);

2.7 前端展示 Agent 推理过程

Agent 的核心体验是让用户看到 AI 在做什么,而不是等待黑盒输出。这里设计一个带有"思考步骤"展示区的对话界面。

2.7.1 useAgent Composable

javascript
// 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 组件

vue
<!-- 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 开发最容易出问题的地方。

几个原则:

说清楚什么时候用,不要只描述工具做什么,更要说用户问什么类型的问题时应该调这个工具。

差的写法:

javascript
description: '查询订单'

好的写法:

javascript
description: '根据订单号查询订单详情,包括订单状态、商品列表、金额、快递信息。当用户询问订单状态、订单内容时调用。'

参数 describe 要说明格式,让 LLM 知道该传什么值:

javascript
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 页面。

bash
# 后端无需重启,已自动加载新路由(nodemon 模式)
# 前端在 router/index.js 中添加路由

client/src/router/index.js 中注册页面:

javascript
import AgentView from '../views/AgentView.vue';

const routes = [
  { path: '/',      component: ChatView  },
  { path: '/agent', component: AgentView },
];

访问 http://localhost:5173/agent,输入"查一下订单 ORD-001 的状态",可以看到:

  1. 界面上实时出现工具调用步骤(调用 getOrderInfo · ORD-001)
  2. 工具执行完毕后 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 知识截止日期和私有数据的问题。