系列: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.json 的 dependencies):
| 包 | 版本 | 作用 |
|---|---|---|
@langchain/core | ^1.2.1 | LangChain 核心(消息、模型基类) |
@langchain/deepseek | ^1.1.3 | DeepSeek 的 ChatModel 封装 |
@langchain/langgraph | ^1.4.7 | 后面的工作流编排(本篇暂未用到) |
express | ^4.21.2 | HTTP 服务 |
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=3000
LLM_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}`);
});
几个值得记住的点:
import 'dotenv/config'必须尽早执行,它把.env读进process.env。放第一行最稳。app.use(express.json())不能少——它负责把前端 POST 的 JSON 请求体解析成req.body。没有这行,req.body是undefined。- 路由统一挂在
/api下,所以你后面看到的/lc/chat/simple实际完整路径是/api/lc/chat/simple。 - 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.js 里 apiRouter.use('/lc', langchanRouter))。
四步拆解:
threadInputSchema.parse(req.body):校验 + 自动填入threadId默认值。校验失败会抛ZodError→ 返回 400。getChatModel():返回ChatDeepSeek实例。await model.invoke(body.input):把用户输入的字符串直接丢给模型。invoke会一次性返回完整结果,是 LangChain 聊天模型最基础的调用方式(后面「流式输出」会换成model.stream())。返回的result是一条AIMessage,回答文本在result.content。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 就说明整条链路通了。
九、踩坑提醒(都是实打实踩过的)
- Node 必须 ≥ 22:
engines锁了,低版本node --watch或某些语法会出问题。 - ESM 的 import 要带扩展名:后端是
"type": "module",所以import { apiRouter } from './routes/index.js'的.js不能省,否则 Node 报「无法解析」。 dotenv/config放最上面:放晚了process.env还没被填,模型工厂拿不到 Key。express.json()别忘了:它是req.body的前提,漏了会校验undefined直接 400。- 模型名别写错:DeepSeek 聊天用
deepseek-chat(不是deepseek-v3之类的),写错会返回模型不存在。 - 本地跨域用代理别用 CORS:本仓库后端没开 cors,靠 Vite 代理;如果你把前端部署到别的端口/域名,记得在后端补
cors()或配反向代理。
十、小结 & 下一篇
这一篇我们跑通了最小 AI 聊天闭环:
- 用 Express 提供 HTTP 接口
- 用 Zod 校验请求参数
- 用 LangChain(
@langchain/deepseek的ChatDeepSeek)创建聊天模型 - 用
model.invoke(input)调用大模型 - 用 Vite 代理 打通前后端,React 发请求拿到回答
请求链路就是:用户输入 → React → POST /api/lc/chat/simple → Express 路由 → LangChain 调模型 → 返回 output → 前端展示。
下一篇我们在这个骨架上加「对话记忆」:让模型记住上一轮说了什么,变成多轮对话(/api/lc/agent/chat + threadId)。
完整 LangChain.js + LangGraph 实战系列(含可运行源码与踩坑笔记)我持续在更新,想跟着敲的同学可以关注公众号「左耳击水兽」,后台回复 LC 拿每篇的配套代码~