基于 langchain 写一个 “山姆买买买”的 agent👋

0 阅读6分钟

前言

之前去 山姆购物,每次都容易拿太多东西,最后不但超了预算而且东西也吃不完。于是和爱人探讨了一下出现问题的原因,有如下两个:

  1. 只有去山姆的动机,但没有先做预算。
  2. 一次性买了太多的食品,健康与不健康的食品比例不够合理。

如果这个问题在过去,我得和我爱人吵好多次才能得到 如何在有限的预算里买到最合适的食物配比,但现在有了 AI,有了 中间人 ,结论更科学🤣。但是如果单纯的去问 AI,它本身的知识和能力有限,因此我们需要实现一个 agent ,让 AI 能够:

  • 🛒 查询商品价格
  • 📊 按预算筛选商品
  • 🛍️ 按分类浏览商品
  • 💰 计算购物总价

同时最要保证的便是让 AI 在它不知道的时候别凭空想象,给我说不存在的商品和价格。

Agent 技术栈

技术版本角色理由
TypeScript5.9.3开发语言类型安全
LangChain1.2.28Agent 框架AI 应用开发,除了 Python 外,它还支持 typescript
@modelcontextprotocol/sdk1.27.1MCP 协议实现用来扩展和解耦
Zod4.3.6Schema 校验运行时类型检查
@langchain/openai1.2.11LLM 接口兼容 OpenAI API

最后大模型提供商选择 硅基流动 的,上面的模型也很丰富。

整体架构图

AI 生图太好用了,除了文字差点🫢

时序图示例

下面展示了当我输入 “我有 30 块钱,能买什么?”时,系统各组件之间的交互时序:

时序说明

阶段步骤说明
启动1-5MCP 客户端连接服务端,动态发现工具
查询6-7用户输入,Agent 请求 LLM
思考8LLM 分析问题,决定调用工具
行动9-14通过 MCP 调用工具,查询数据库
观察15-16LLM 接收工具结果,继续思考
回答17-19生成最终答案,展示给用户

关键交互点

  1. 动态发现(步骤 3-5):Agent 运行时才获取工具列表
  2. Schema 转换(步骤 6):MCP JSON Schema → Zod Schema
  3. Stdio 通信(步骤 10):通过标准输入输出跨进程通信
  4. ReAct 循环(步骤 8-16):思考-行动-观察的循环过程

Agent 工程哲学

1. 关注点分离

服务端:只负责提供工具,不关心谁在用

客户端:负责思考和组织,不关心工具怎么实现

数据层:纯粹的数据存储,随时可以替换

2. 标准化接口

所有工具都遵循 MCP 协议。

3. 动态发现

AI 不需要预先知道有哪些工具,运行时自己探索。这就像我们去超市,不需要提前知道卖什么,到了现场自然能发现。

核心实现

1. 大模型选择

由于我们涉及到一些工具调用,不想写 prompt 进行维护,只能选择一些针对工具调用的微调模型

这里我选了 Qwen/Qwen3-Omni-30B-A3B-Thinking (主要是价格便宜🤣)

2. 具体实现

MCP 服务端:工具提供者

src/mcp.ts 是整个项目的"工具库",提供了 7 个实用工具。

1. 数据模型
interface Product {
  name: string;       // 商品名称
  price: number;      // 价格(单位:元)
  category: string;   // 分类:水果、饮品、食品、肉类、粮食、电子产品
  description: string; // 描述
}

这里我们 mock 一些商品

const productDatabase: Record<string, Product> = {
  "苹果": { name: "苹果", price: 5.5, category: "水果", description: "新鲜红富士苹果,500g" },
  "香蕉": { name: "香蕉", price: 3.0, category: "水果", description: "进口香蕉,500g" },
  "橙子": { name: "橙子", price: 4.5, category: "水果", description: "赣南脐橙,500g" },
  "牛奶": { name: "牛奶", price: 8.0, category: "饮品", description: "纯牛奶,1L装" },
  "面包": { name: "面包", price: 6.0, category: "食品", description: "全麦面包,切片装" },
  "鸡蛋": { name: "鸡蛋", price: 12.0, category: "食品", description: "土鸡蛋,10枚装" },
  "猪肉": { name: "猪肉", price: 25.0, category: "肉类", description: "新鲜五花肉,500g" },
  "牛肉": { name: "牛肉", price: 45.0, category: "肉类", description: "澳洲牛腩,500g" },
  "鸡肉": { name: "鸡肉", price: 18.0, category: "肉类", description: "农家土鸡,500g" },
  "大米": { name: "大米", price: 3.5, category: "粮食", description: "东北大米,500g" },
  "笔记本电脑": { name: "笔记本电脑", price: 5999, category: "电子产品", description: "轻薄本,16GB内存" },
  "手机": { name: "手机", price: 3999, category: "电子产品", description: "智能手机,256GB存储" },
  "耳机": { name: "耳机", price: 299, category: "电子产品", description: "无线蓝牙耳机" },
  "键盘": { name: "键盘", price: 199, category: "电子产品", description: "机械键盘,RGB背光" },
  "鼠标": { name: "鼠标", price: 99, category: "电子产品", description: "无线鼠标,静音设计" },
};
2. 工具列表
工具名功能输入参数返回值
list_products列出所有商品商品名称列表
get_price查询价格name: string商品价格
get_product_info获取详情name: string完整商品信息
list_by_category分类查询category: string分类下商品列表
list_categories获取所有分类分类列表
find_within_budget预算筛选budget: number预算内商品列表
calculate_total计算总价products: string[]总价和明细
3. 典型工具实现
server.tool(
  "find_within_budget",
  "查找预算范围内的商品,返回价格低于或等于预算的所有商品",
  { budget: z.number().describe("预算金额(元)") },
  async ({ budget }) => {
    const affordableProducts = Object.values(productDatabase)
      .filter((p) => p.price <= budget)
      .sort((a, b) => a.price - b.price)  // 按价格升序
      .map((p) => `${p.name}(${p.price}元)`);

    if (affordableProducts.length === 0) {
      return {
        content: [{
          type: "text" as const,
          text: `没有找到价格在 ${budget} 元以内的商品`
        }],
      };
    }
    return {
      content: [{
        type: "text" as const,
        text: `价格在 ${budget} 元以内的商品有:${affordableProducts.join("、")}`
      }],
    };
  }
);
4. 传输方式
const transport = new StdioServerTransport();
await server.connect(transport);

使用 Stdio (标准输入输出) 进行进程间通信,简单、可靠、跨平台。

Agent 客户端:思考者

src/index.ts 是整个项目的"大脑",负责连接服务、思考问题、组织答案。

1. MCP 客户端连接
async function connectMcpServer(): Promise<Client> {
  const client = new Client(
    { name: "shopping-assistant-client", version: "1.0.0" },
    { capabilities: {} }
  );

  const transport = new StdioClientTransport({
    command: "npx",
    args: ["ts-node", "src/mcp.ts"],  // 启动 MCP 服务端,动态链接
  });

  await client.connect(transport);
  return client;
}
2. 动态工具发现

AI 在运行时动态发现可用工具,而不是硬编码。

//* 获取服务端提供的所有工具
const toolsList = await mcpClient.listTools();

//* 将 MCP 工具转换为 LangChain 工具
const tools = toolsList.tools.map((tool) => {
  const inputSchema = convertMcpSchemaToZod(tool.inputSchema);
  // 创建工具实例
  return createLangChainToolFromMcp(
    mcpClient,
    tool.name,
    tool.description!,
    inputSchema
  );
});
3. ReAct Agent 创建

使用 LangChain 的预构建 ReAct Agent:

const agent = createReactAgent({
  llm,
  tools, 
  messageModifier: systemPrompt,
});

ReAct (Reasoning + Acting) 模式

  1. Thought (思考):分析问题,决定下一步
  2. Action (行动):选择并调用工具
  3. Observation (观察):获取工具返回结果
  4. 重复:直到得出最终答案

动态提示词生成

function generateSystemPrompt(tools: { name: string; description: string }[]): string {
  const toolsDescription = tools
    .map((t) => `- ${t.name}: ${t.description}`)
    .join("\n");

  return `你是一个智能购物助手。你会使用 ReAct (Reasoning and Acting) 模式来处理用户的请求。

你的工作流程:
1. Thought: 思考用户的问题,决定下一步行动
2. Action: 选择合适的工具并执行
3. Observation: 观察工具返回的结果
4. 重复以上步骤直到得出最终答案

可用工具:
${toolsDescription}

重要提示:
- 当用户询问复杂问题时,需要多次调用工具收集信息
- 例如"我有50元能买什么",先调用 find_within_budget,再详细查看商品信息
- 例如"推荐一些水果",先调用 list_by_category,再查询具体价格
- 例如"买个早餐组合",需要选择多个商品并计算总价

请始终用中文回答用户问题。`;
}

一些效果

  1. pnpm start 启动 agent upload_0w16xrdq6pkuiow288l97kj3lma557wz.png

  2. 查询 我有 100 块用来买早餐,我想吃 5 天,怎么买合适

    upload_dvzp89h8lfl6qsdue4wwo4tz67m57dne.png 但在第一次回答中,AI 认为我一天可以吃 10 个鸡蛋,哈哈哈😄。 upload_b6bv8tgrv1d0qc7hj1hpv816trxkhrx3.png 于是接着问它:你看看鸡蛋是几枚装的,我一天最多吃两个鸡蛋。 但由于没加 记忆,所以它的回答也是不相关的了,但还是走的工具,比较科学。 upload_i8eu56pw05zmrjea4xqcqdm6bcstvy21.png

最后的话

目前这个状态算是只有我本地跑着玩,距离上线让我爱人使用还早,山姆的商品那么多,还得收集一段时间。后续有如下计划:

  1. 加入记忆,应对多轮对话。
  2. 加入 Rag,有好多信息,比如某个食品是否达到健康标准,是否好吃 这些还得从 小红书 上爬一爬。
  3. 服务上线,东西还很多,任重而道远呀。