← 博客书房

Agent 实战:从 Dify 到 LangGraph·实战:重构 cooking-app 的 AI 服务

实战30 分钟

实战:重构 cooking-app 的 AI 服务

实战锚点 · cooking-app:把 ai.service.ts 从裸 SDK 重构成 LangChain.js(工具 + 记忆 + 结构化输出全套)。

把前面所有章节学到的东西,用到一个真实项目上。从裸 SDK 到 LangChain 全家桶,每一步都有为什么。

起点:168 行裸 SDK

cooking-app 的 ai.service.ts 是一个典型的「够用但没有扩展性」的 AI 服务:

cooking-app AI 服务重构路线图 重构前(裸 SDK) fetch() 手动拼请求 buildSystemPrompt() 硬编码 无流式(stream: false) 无工具调用 无记忆(每次独立) 无结构化输出 168 行 · 能跑 · 难扩展 → 重构后(LangChain) ChatDeepSeek 统一接口 ChatPromptTemplate 模板化 .stream() 流式输出 searchRecipe() 等工具 ChatMessageHistory 记忆 StructuredOutputParser ~120 行 · 可扩展 · 可测试

第一步:替换模型调用

为什么:裸 fetch 没有类型提示、没有重试、没有流式。ChatDeepSeek 给你这一切。

// 重构前
const response = await fetch(`${this.deepseekApiUrl}/chat/completions`, { … });

// 重构后
import { ChatOpenAI } from "@langchain/openai";

const llm = new ChatOpenAI({
  modelName: "deepseek-chat",
  openAIApiKey: process.env.DEEPSEEK_API_KEY,
  temperature: 0.7,
});

第二步:模板化 System Prompt

为什么:buildSystemPrompt() 用字符串拼接注入上下文,难维护、易出错。

// 重构前
private buildSystemPrompt(context?: any): string {
  let prompt = `你是一个专业的烹饪助手…`;
  if (context?.userPreferences) {
    prompt += `\n\n用户偏好:${JSON.stringify(context.userPreferences)}`;
  }
  return prompt;
}

// 重构后
import { ChatPromptTemplate } from "@langchain/core/prompts";

const prompt = ChatPromptTemplate.fromMessages([
  ["system", `你是一个专业的烹饪助手。
    {% if userPreferences %}用户偏好:{{userPreferences}}{% endif %}`],
  ["human", "{question}"],
]);

第三步:加流式输出

为什么:用户等 5 秒看到完整回答 vs 逐字看到回答,体感差距巨大。

// 重构前:stream: false,等完整响应
const data = await response.json();
return data.choices[0].message.content;

// 重构后:流式
const stream = await llm.stream(await prompt.format({ question }));
for await (const chunk of stream) {
  this.eventEmitter.emit("token", chunk.content);
}

第四步:加工具

为什么:用户问「鸡胸肉有多少蛋白质」,模型不知道你的菜谱数据——它需要工具去查。

import { tool } from "@langchain/core/tools";
import { z } from "zod";

const searchRecipe = tool(
  async ({ keyword }) => {
    return await this.recipeService.search(keyword);
  },
  {
    name: "search_recipe",
    description: "搜索菜谱,返回菜名、食材、步骤",
    schema: z.object({
      keyword: z.string().describe("搜索关键词"),
    }),
  }
);

第五步:加记忆

为什么:用户第一句「我不吃香菜」,第三句「推荐个菜」——模型需要记住偏好。

import { ChatMessageHistory } from "@langchain/community/stores/message";

// NestJS 中注入为全局单例
private history = new ChatMessageHistory();

// 多轮对话
const chatHistory = await this.history.getMessages();
const response = await chain.invoke({
  chat_history: chatHistory,
  question: userMessage,
});
await this.history.addUserMessage(userMessage);
await this.history.addAIMessage(response.content);

第六步:结构化输出

为什么:前端需要 JSON 格式的菜谱数据(菜名/食材/步骤),不是一大段文字。

const recipeSchema = z.object({
  name: z.string(),
  ingredients: z.array(z.object({
    name: z.string(),
    amount: z.string(),
  })),
  steps: z.array(z.string()),
  cookingTime: z.number(),
});

const structuredLlm = llm.withStructuredOutput(recipeSchema);
const recipe = await structuredLlm.invoke(prompt);
// recipe 直接是类型安全的 TypeScript 对象

重构收益清单

维度重构前重构后
换模型供应商改 URL + 改请求体格式改一个 modelName
加新工具不支持定义 tool + 加到数组
多轮对话不支持ChatMessageHistory
流式输出不支持.stream()
输出格式纯文本,前端自己解析Zod Schema → 类型安全
错误重试手动写 try-catch框架内置
可测试性需要真实 API可以 mock LLM

三层对比:裸 SDK → Dify → LangChain

裸 SDKDifyLangChain
上手最快(直接写)快(拖拽)中(学 API)
灵活性最高(什么都能改)最低(平台限制)高(框架内自由组合)
部署你的服务器平台托管你的服务器
适合原型验证非开发者使用生产级产品

建议路线:裸 SDK 快速验证 → Dify 给非开发者演示 → LangChain 做产品。每一步都是「前一步不够用了才升级」。

本章要点

  1. 重构六步:替换调用 → 模板化 → 流式 → 工具 → 记忆 → 结构化输出
  2. 每步的「为什么」比「怎么做」更重要
  3. 裸 SDK 灵活性最高但样板代码最多;Dify 零代码但天花板低;LangChain 是平衡点
  4. 不要一步到位——先用裸 SDK 验证,不够用了再升级

→ 回到目录