用 Express + LangChain.js 跑通第一个 AI 聊天接口(前端 Node/React 全链路)

0 阅读5分钟

系列:LangChain.js 实战 · 第 1 篇 / 共 6 篇 标签:LangChain.js · Express · 前端转 AI · 大模型应用

前端同学想做 AI 应用,第一道坎通常不是写代码,而是「环境怎么搭、第一个能跑起来的接口长什么样」。这篇就带你把这条路跑通:后端用 Node.js + Express + LangChain.js,前端用 React + TypeScript,最后在浏览器里输入一句话、拿到大模型回答。

这个最小闭环虽然简单,但它是后面「流式输出、工具调用、RAG、LangGraph 工作流」全部能力的基础。先把这一条链路吃透,后面都是在这个骨架上加东西。


一、项目结构先看清

仓库是「一后端一前端」的标准前后端分离结构:

langchain-langgraph-express-demos/
├── package.json          # 后端依赖与脚本(Express + LangChain)
├── .env                  # 环境变量:端口、模型、API Key
├── src/                  # 后端(Node.js + JavaScript,ESM)
│   ├── server.js         # Express 入口
│   ├── ai/
│   │   ├── models.js     # 聊天模型工厂 getChatModel()
│   │   ├── tools.js      # 工具调用
│   │   ├── rag-store.js  # RAG 最小演示
│   │   ├── sse.js        # 流式输出辅助
│   │   └── structured.js # 结构化输出
│   ├── routes/
│   │   ├── index.js          # 总路由
│   │   ├── langchain.routes.js  # LangChain 相关接口(含基础聊天)
│   │   └── langgraph.routes.js  # LangGraph 相关接口
│   └── shared/
│       ├── async-route.js  # 异步路由错误捕获
│       └── validation.js   # Zod 参数校验
└── web/                  # 前端(React + TypeScript + Vite)
    ├── package.json
    ├── vite.config.ts     # 开发代理 /api -> http://127.0.0.1:3000
    └── src/
        ├── api.ts         # fetch 封装(postJson / getJson / streamPost)
        ├── App.tsx        # 页面与交互
        └── main.tsx

关键点:后端是纯 JavaScript(src/ 下全是 .js,ESM 模块)前端是 TypeScript(web/src.tsx / .ts 。这也是很多前端同学上手最舒服的搭配——后端不用换语言,前端还是你熟悉的 React。


二、环境准备

1. Node 版本

package.json 里写死了:

"engines": { "node": ">=22" }

本地请确认 Node ≥ 22:

node -v

2. 安装后端依赖

npm install

后端实际用到的依赖(看 package.jsondependencies):

版本作用
@langchain/core^1.2.1LangChain 核心(消息、模型基类)
@langchain/deepseek^1.1.3DeepSeek 的 ChatModel 封装
@langchain/langgraph^1.4.7后面的工作流编排(本篇暂未用到)
express^4.21.2HTTP 服务
dotenv^16.4.7读取 .env
zod^3.24.1请求参数校验
cors^2.8.5(依赖已装,但本仓库用 Vite 代理解决跨域,后端未直接启用)

注意:这里用的是 LangChain v1 线@langchain/core@^1@langchain/deepseek@^1)。网上很多老教程还是 0.2 / 0.3,API 名字基本一致,但建议以你装的版本为准,别混着抄。

3. 配置 .env

仓库根目录的 .env 长这样(请换成你自己的 Key):

PORT=3000LLM_PROVIDER=deepseek
​
DEEPSEEK_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxx
DEEPSEEK_MODEL=deepseek-chat
  • PORT:后端监听端口。
  • LLM_PROVIDER:模型供应商,目前只实现了 deepseek
  • DEEPSEEK_API_KEY:DeepSeek 开放平台的 API Key(必填,没填会直接报错)。
  • DEEPSEEK_MODEL:模型名,仓库里实际写死用了 deepseek-chat,这个变量主要给你切换模型用。

4. 启动后端

package.json 的脚本:

"scripts": {
  "dev": "node --watch src/server.js",
  "dev:api": "node --watch src/server.js",
  "start": "node dist/server.js",
  "typecheck": "tsc --noEmit"
}

开发模式直接:

npm run dev

看到输出 接口已经启动在: http://127.0.0.1:3000 就说明后端起来了。--watch 表示改代码会自动重启,不用手动 kill 再启。


三、后端入口:server.js

src/server.js 是 Express 的入口,做的事情很克制——加载环境变量、解析 JSON、挂路由、兜底错误处理:

import 'dotenv/config';
import express, { Router } from 'express';
import { apiRouter } from './routes/index.js';
​
const app = express();
const port = Number(process.env.PORT ?? 3000);
​
// 返回数据处理成 json
app.use(express.json());
​
// 应用路由:所有接口挂在 /api 下
app.use('/api', apiRouter);
​
// 404 兜底
app.use((req, res) => {
  res.status(404).json({ ok: false, error: "Not Found", message: "Route not found" });
});
​
// 统一错误处理
app.use((error, req, res, next) => {
  if (error instanceof ZodError) {
    res.status(400).json({
      ok: false,
      error: "验证输入错误",
      message: "请查看请求体",
      details: error.flatten(),
    });
    return;
  }
  res.status(500).json({ ok: false, error: "服务器错误", message: error.message });
});
​
app.listen(port, () => {
  console.log(`接口已经启动在: http://127.0.0.1:${port}`);
});

几个值得记住的点:

  1. import 'dotenv/config' 必须尽早执行,它把 .env 读进 process.env。放第一行最稳。
  2. app.use(express.json()) 不能少——它负责把前端 POST 的 JSON 请求体解析成 req.body。没有这行,req.bodyundefined
  3. 路由统一挂在 /api,所以你后面看到的 /lc/chat/simple 实际完整路径是 /api/lc/chat/simple
  4. ZodError 单独处理成 400,这是和参数校验配合的(见第五节)。

顺带提一句:很多教程里会 app.use(cors()) 解决跨域。这个仓库没用 cors,而是让前端用 Vite 代理把 /api 转发到后端(见第七节)。两种方式都行,代理的方式对本地开发更省事。


四、聊天模型工厂:ai/models.js

模型创建被单独封装成 getChatModel(),路由层不用关心用哪个模型:

import { ChatDeepSeek } from '@langchain/deepseek';

export const getChatModel = () => {
  const provide = process.env.LLM_PROVIDER ?? 'deepseek';
  if (provide === 'deepseek') {
    if (!process.env.DEEPSEEK_API_KEY) {
      throw new Error('没有 api_key');
    }
    return new ChatDeepSeek({
      apiKey: process.env.DEEPSEEK_API_KEY,
      model: 'deepseek-chat'
    });
  }
  throw new Error('没有大模型');
};

这里有几个实战细节:

  • @langchain/deepseek 导入 ChatDeepSeek,不是从 @langchain/openai。DeepSeek 官方提供了自己的 LangChain 集成包,用起来最顺。
  • model: 'deepseek-chat' 是写死的。如果你想用 DEEPSEEK_MODEL 环境变量切换,可以改成 model: process.env.DEEPSEEK_MODEL ?? 'deepseek-chat'
  • 没有 Key 直接 throw,错误会被第三节的错误处理中间件接住,返回 500。

new ChatDeepSeek(...) 出来的是一个「聊天模型对象」,后面 model.invoke(...) 就是真正去调 DeepSeek 接口。


五、参数校验与异步路由封装

1. Zod 校验:shared/validation.js

基础聊天只需要一个非空 input(外加一个带默认值的 threadId,给后面的「带记忆」用):

import { z } from 'zod';

export const threadInputSchema = z.object({
  input: z.string().min(1),
  threadId: z.string().min(1).default('demo-thread')
});

threadId 给了默认值 'demo-thread',所以本篇基础聊天你只传 input 也能跑。

2. 异步路由封装:shared/async-route.js

因为接口里全是 async/await,如果不包一层,路由里抛的错 Express 抓不到。封装一下统一交给错误中间件:

export const asyncRoutes = (handler) => {
  return (req, res, next) => {
    void handler(req, res, next).catch(next);
  };
};

void ... .catch(next) 的意思是:handler 如果 reject,就把错误传给 next(),也就是第三节那个统一错误处理。写一次,所有路由复用。


六、基础聊天接口:/api/lc/chat/simple

核心来了,在 src/routes/langchain.routes.js 里:

import { Router } from "express";
import { asyncRoutes } from '../shared/async-route.js';
import { threadInputSchema } from '../shared/validation.js';
import { getChatModel } from '../ai/models.js';

export const langchanRouter = Router();

langchanRouter.post(
  "/chat/simple",
  asyncRoutes(async (req, res) => {
    const body = threadInputSchema.parse(req.body);  // 1. 校验参数
    const model = getChatModel();                    // 2. 拿模型
    const result = await model.invoke(body.input);   // 3. 调大模型
    res.json({                                        // 4. 返回结果
      ok: true,
      data: {
        input: body.input,
        output: result.content,
      }
    });
  })
);

/chat/simple 会挂到 /api/lc/chat/simple(因为 routes/index.jsapiRouter.use('/lc', langchanRouter))。

四步拆解:

  1. threadInputSchema.parse(req.body) :校验 + 自动填入 threadId 默认值。校验失败会抛 ZodError → 返回 400。
  2. getChatModel() :返回 ChatDeepSeek 实例。
  3. await model.invoke(body.input) :把用户输入的字符串直接丢给模型。invoke 会一次性返回完整结果,是 LangChain 聊天模型最基础的调用方式(后面「流式输出」会换成 model.stream())。返回的 result 是一条 AIMessage,回答文本在 result.content
  4. res.json(...) :按 { ok, data: { input, output } } 的统一格式返回,前端好解析。

七、前端怎么调(React + TS)

1. Vite 代理解决跨域

web/vite.config.ts

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  server: {
    proxy: {
      "/api": "http://127.0.0.1:3000",
    },
  },
});

前端开发服务器(默认 5173)收到 /api/... 请求时,会自动转发到后端 3000 端口。所以前端代码里直接写 /api/lc/chat/simple 就行,不用写完整域名、也不用后端开 CORS。

2. fetch 封装:web/src/api.ts

export type ApiResult<T = unknown> = {
  ok: boolean;
  output?: string;
  data?: T;
  error?: string;
  message?: string;
};

export async function postJson<T>(url: string, body: unknown): Promise<T> {
  const response = await fetch(url, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
  const payload = (await response.json()) as ApiResult<T>;
  if (!response.ok || !payload.ok) {
    throw new Error(payload.message ?? payload.error ?? `Request failed: ${response.status}`);
  }
  return payload.data as T;
}

postJson 封装了「发 POST + 解析 JSON + 检查 ok 字段」这套重复动作,返回 payload.data(也就是后端 { data: {...} } 里的 data)。

3. 页面里调一次基础聊天

import { useState } from "react";
import { postJson } from "./api";

type ChatData = { input: string; output: string };

export function ChatBox() {
  const [input, setInput] = useState("");
  const [output, setOutput] = useState("");

  const send = async () => {
    const data = await postJson<ChatData>("/api/lc/chat/simple", { input });
    setOutput(data.output);
  };

  return (
    <div>
      <input value={input} onChange={(e) => setInput(e.target.value)} />
      <button onClick={send}>发送</button>
      <pre>{output}</pre>
    </div>
  );
}

前端负责「收输入、发请求、展示 output」,后端负责「调模型」。职责分得很干净。

启动前端(另开一个终端):

cd web
npm install
npm run dev

打开 http://127.0.0.1:5173,输入框打字、点发送,就能看到 DeepSeek 的回答。


八、直接用 curl 验证(最快确认后端跑通)

不想开前端也能验证后端:

curl -X POST http://127.0.0.1:3000/api/lc/chat/simple \
  -H "Content-Type: application/json" \
  -d '{"input":"你好,你是谁?"}'

返回:

{
  "ok": true,
  "data": {
    "input": "你好,你是谁?",
    "output": "我是 DeepSeek 开发的智能助手……"
  }
}

能拿到 output 就说明整条链路通了。


九、踩坑提醒(都是实打实踩过的)

  1. Node 必须 ≥ 22engines 锁了,低版本 node --watch 或某些语法会出问题。
  2. ESM 的 import 要带扩展名:后端是 "type": "module",所以 import { apiRouter } from './routes/index.js'.js 不能省,否则 Node 报「无法解析」。
  3. dotenv/config 放最上面:放晚了 process.env 还没被填,模型工厂拿不到 Key。
  4. express.json() 别忘了:它是 req.body 的前提,漏了会校验 undefined 直接 400。
  5. 模型名别写错:DeepSeek 聊天用 deepseek-chat(不是 deepseek-v3 之类的),写错会返回模型不存在。
  6. 本地跨域用代理别用 CORS:本仓库后端没开 cors,靠 Vite 代理;如果你把前端部署到别的端口/域名,记得在后端补 cors() 或配反向代理。

十、小结 & 下一篇

这一篇我们跑通了最小 AI 聊天闭环:

  • Express 提供 HTTP 接口
  • Zod 校验请求参数
  • LangChain@langchain/deepseekChatDeepSeek)创建聊天模型
  • model.invoke(input) 调用大模型
  • Vite 代理 打通前后端,React 发请求拿到回答

请求链路就是:用户输入 → React → POST /api/lc/chat/simple → Express 路由 → LangChain 调模型 → 返回 output → 前端展示

下一篇我们在这个骨架上加「对话记忆」:让模型记住上一轮说了什么,变成多轮对话(/api/lc/agent/chat + threadId)。


完整 LangChain.js + LangGraph 实战系列(含可运行源码与踩坑笔记)我持续在更新,想跟着敲的同学可以关注公众号「左耳击水兽」,后台回复 LC 拿每篇的配套代码~