前面学了 LangChain 的各种功能,但都是在 Node.js 脚本里跑的。而实际上大多数 Agent 都是跑在后端服务里的。
比如你和豆包聊天的时候,它会调用 AI 接口,把你的问题传给后端,后端流式返回生成的回答。
这一节我们就来学一下 LangChain 和后端框架结合,开发 AI 接口。
我们用 Nest 这个后端框架,它是 Node.js + TypeScript 最主流的框架:底层是 Express,封装后提供了 MVC、DI(依赖注入)等架构特性。
npm install -g @nestjs/cli
nest new hello-nest-langchain进入项目目录看一下,它是 MVC 架构:
/list 的 get 接口、/create 的 post 接口@Module 声明模块,里面的 controllers 数组放本模块的 Controller,providers 数组放本模块的 service 等,imports 是引用的其他模块。
我们创建一个 crud 的模块:
nest g res book --no-spec从根模块 AppModule 引入 BookModule,这样 BookController 里的路由就会生效了。
Nest 支持 DI(Dependency Injection)依赖注入。
也就是你不用手动 new 依赖对象,只要声明一下,运行的时候会自动注入依赖的实例对象。
比如这里用 @Injectable 声明了 BookService:
@Injectable()
export class BookService {
findAll() {
return [
{ id: 1, title: 'Book 1' },
{ id: 2, title: 'Book 2' },
];
}
}然后 BookController 里在构造器声明了依赖:
@Controller('book')
export class BookController {
constructor(private readonly bookService: BookService) {}
}这样运行的时候就会自动注入 BookService 的实例对象。
这样一个好处是所有的依赖都是单例的,不用自己去 new。这也是为啥叫 providers:就是可以提供某种能力的对象。
用 @Injectable 声明的 class 只是一种,你也可以这样创建 provider,用 useFactory 函数返回一个对象,它也可以作为 provider 来用,provide 是名字:
import { Module } from '@nestjs/common';
import { BookService } from './book.service';
import { BookController } from './book.controller';
@Module({
controllers: [BookController],
providers: [
BookService,
{
provide: 'BOOK_REPOSITORY',
useFactory() {
// 内存 mock 仓库,适合测试,无需外部依赖
const books: { id: number; title: string }[] = [
{ id: 1, title: 'Book 1' },
{ id: 2, title: 'Book 2' },
{ id: 3, title: 'Book 3' },
];
return {
findAll: () => [...books],
};
},
},
],
})
export class BookModule {}你可以基于这个依赖名字来注入,这里用到了属性注入的方式,之前是构造器参数的注入,两种都可以:
@Controller('book')
export class BookController {
@Inject('BOOK_REPOSITORY')
private readonly bookRepository: any;
}访问 http://localhost:3000/book,可以看到用 useFactory 创建的 provider 也被成功注入了。
大概理解了 Nest 的模块、依赖注入之后,我们就可以来结合 LangChain 写 AI 接口了。
安装依赖:
pnpm install @langchain/core @langchain/openai生成一个 ai 的模块:
nest g res ai --no-spec然后在 AiService 里调用 langchain 创建一个 chain:
import { Injectable } from '@nestjs/common';
import { ChatOpenAI } from '@langchain/openai';
import { PromptTemplate } from '@langchain/core/prompts';
import type { Runnable } from '@langchain/core/runnables';
import { StringOutputParser } from '@langchain/core/output_parsers';
@Injectable()
export class AiService {
private readonly chain: Runnable;
constructor() {
const prompt = PromptTemplate.fromTemplate(
'请回答以下问题:\n\n{query}',
);
const model = new ChatOpenAI({
temperature: 0.7,
modelName: 'qwen-plus',
apiKey: 'sk-xxx',
configuration: {
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
},
});
this.chain = prompt.pipe(model).pipe(new StringOutputParser());
}
async runChain(query: string): Promise<string> {
return this.chain.invoke({ query });
}
}在构造器里创建 ChatModel、chain,避免重复创建(这里 apikey 之类的先写在代码里,后面优化)。
然后在 AiController 里加一个路由:
import { Controller, Get, Query } from '@nestjs/common';
import { AiService } from './ai.service';
@Controller('ai')
export class AiController {
constructor(private readonly aiService: AiService) {}
@Get('chat')
async chat(@Query('query') query: string) {
const answer = await this.aiService.runChain(query);
return { answer };
}
}接收 query 参数,调用大模型来回答问题。跑一下,这样第一个 AI 接口就完成了。
但现在有两个问题:
配置的话用这个包:
pnpm install @nestjs/config在 AppModule 里引入 ConfigModule。它的作用就是读取 .env 配置文件,提供一个 service 来读配置。isGlobal 设置为 true 就是全局模块,也就是不用 imports 就可以注入里面的 provider:
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
}),
BookModule,
AiModule,
],
})
export class AppModule {}然后我们在根目录创建一个 .env 文件:
OPENAI_API_KEY=sk-xxx
OPENAI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
MODEL_NAME=qwen-plus现在配置就可以用 ConfigService 动态读取了。注意这里只能用构造器注入,因为这时候还没创建对象,没法用属性注入:
@Injectable()
export class AiService {
private readonly chain: Runnable;
constructor(configService: ConfigService) {
const prompt = PromptTemplate.fromTemplate('请回答以下问题:\n\n{query}');
const model = new ChatOpenAI({
temperature: 0.7,
modelName: configService.get('MODEL_NAME'),
apiKey: configService.get('OPENAI_API_KEY'),
configuration: {
baseURL: configService.get('OPENAI_BASE_URL'),
},
});
this.chain = prompt.pipe(model).pipe(new StringOutputParser());
}
}注意坑:
AiModule要在ConfigModule.forRoot之后导入,否则 AiModule 初始化时 ConfigService 还没加载.env文件,读出来的OPENAI_API_KEY会是空,导致调用模型时报找不到 API Key。
接下来实现流式返回,这种不断返回内容一般用 SSE(Server-Sent Event) 来做。
SSE 是这样的流程:服务端返回的 Content-Type 是 text/event-stream,这是一个流,可以多次返回内容。
在 AiService 里加一个流式的接口,调用 chain 的 stream 方法,流式返回内容。这里用到了 JS 的生成器语法,也就是方法名那里标个 *,然后 yield 不断异步返回内容。你没用过这个语法也没关系,理解意思就行,过一遍就会了:
async *streamChain(query: string): AsyncGenerator<string> {
const stream = await this.chain.stream({ query });
for await (const chunk of stream) {
yield chunk;
}
}然后在 AiController 里调用下这个方法,加一个 chat/stream 接口。声明接口是 sse 的,然后创建一个 Observable,从 service 的返回流里读取内容,用 map 转成有 data 属性的对象:
import { Controller, Get, Query, Sse } from '@nestjs/common';
import { from, map } from 'rxjs';
import { AiService } from './ai.service';
@Controller('ai')
export class AiController {
constructor(private readonly aiService: AiService) {}
@Get('chat')
async chat(@Query('query') query: string) {
const answer = await this.aiService.runChain(query);
return { answer };
}
@Sse()
@Get('chat/stream')
chatStream(@Query('query') query: string) {
return from(this.aiService.streamChain(query)).pipe(
map((chunk) => ({
data: chunk,
})),
);
}
}这个是 rxjs 的写法,Nest 用 rxjs 来处理异步流。其实和 LCEL 的声明式写法思路一样,就是声明对这个流做什么处理。
跑一下,可以看到通过 SSE 的接口就可以流式返回内容了。
有的同学可能不知道 SSE 的接口怎么调用,我们写一下前端代码。创建 public/sse-test.html:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SSE 流式接口测试</title>
<style>
* { box-sizing: border-box; }
body {
font-family: system-ui, -apple-system, sans-serif;
max-width: 640px;
margin: 2rem auto;
padding: 0 1rem;
}
label { display: block; margin-bottom: 0.5rem; font-weight: 500; }
input[type="text"] {
width: 100%;
padding: 0.75rem;
border: 1px solid #ccc;
border-radius: 6px;
font-size: 1rem;
margin-bottom: 1rem;
}
button {
padding: 0.6rem 1.2rem;
font-size: 1rem;
border: none;
border-radius: 6px;
cursor: pointer;
}
button.primary { background: #2563eb; color: white; }
button.primary:hover { background: #1d4ed8; }
button:disabled { opacity: 0.6; cursor: not-allowed; }
.output {
margin-top: 1.5rem;
padding: 1rem;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #f9fafb;
min-height: 120px;
white-space: pre-wrap;
word-break: break-word;
}
.output:empty::before {
content: "回复将显示在这里...";
color: #9ca3af;
}
.status { margin-top: 0.5rem; font-size: 0.875rem; color: #6b7280; }
</style>
</head>
<body>
<h1>SSE 流式接口测试</h1>
<label for="apiUrl">API 地址</label>
<input
type="text"
id="apiUrl"
value="http://localhost:3000"
placeholder="http://localhost:3000"
/>
<label for="query">问题</label>
<input
type="text"
id="query"
placeholder="例如:什么是 LangChain?"
value="什么是 LangChain?"
/>
<button type="button" id="btn" class="primary">开始流式请求</button>
<p class="status" id="status"></p>
<div class="output" id="output"></div>
<script>
const apiUrlInput = document.getElementById("apiUrl");
const queryInput = document.getElementById("query");
const btn = document.getElementById("btn");
const output = document.getElementById("output");
const status = document.getElementById("status");
btn.addEventListener("click", () => {
const baseUrl = apiUrlInput.value.replace(/\/$/, "");
const q = queryInput.value.trim();
if (!q) {
status.textContent = "请输入问题";
return;
}
const url = `${baseUrl}/ai/chat/stream?query=${encodeURIComponent(q)}`;
output.textContent = "";
btn.disabled = true;
status.textContent = "连接中...";
const eventSource = new EventSource(url);
eventSource.onmessage = ({ data }) => {
output.textContent += data;
status.textContent = "接收中...";
};
eventSource.onerror = () => {
eventSource.close();
btn.disabled = false;
status.textContent = "连接已结束";
};
eventSource.addEventListener("done", () => {
eventSource.close();
btn.disabled = false;
status.textContent = "完成";
});
});
</script>
</body>
</html>样式是让 AI 写的,不用管,只看这部分:就是调用 EventSource 的 API,在 onmessage 回调里接收 data 就可以了:
const eventSource = new EventSource(url);
eventSource.onmessage = ({ data }) => {
output.textContent += data;
status.textContent = "接收中...";
};然后让 Nest 服务支持静态 html 文件访问,安装下用到的包:
pnpm install @nestjs/serve-static在 AppModule 里引入 ServeStaticModule:
import { ServeStaticModule } from '@nestjs/serve-static';
import { join } from 'path';
@Module({
imports: [
ServeStaticModule.forRoot({
rootPath: join(__dirname, '..', 'public'),
}),
],
})
export class AppModule {}跑一下,这就是 SSE 流式返回内容的体验,AI 接口基本都用这种方式来做流式功能。
补充两点:
- 前端监听了
done事件来标记"完成",所以后端 SSE 接口可以加一个endWith处理,在流结束时发一个 done 事件:endWith({ type: 'done', data: 'completed' })。EventSource只能发 GET 请求,如果 query 字符串很长(比如把整段文档塞进去),不太适合这种方式,可以用fetch请求,它支持 POST 也支持流式读取。
回过头来优化下代码:现在这样写是 service 和具体的 ChatModel 耦合了,实际上应该拆分出去,动态注入。
我们刚学了 useFactory 的方式,用它来创建 ChatModel 的 provider:
import { Module } from '@nestjs/common';
import { AiController } from './ai.controller';
import { AiService } from './ai.service';
@Module({
controllers: [AiController],
providers: [
AiService,
{
provide: 'CHAT_MODEL',
useFactory() {
return new ChatOpenAI({
temperature: 0.7,
modelName: process.env.MODEL_NAME,
apiKey: process.env.OPENAI_API_KEY,
configuration: {
baseURL: process.env.OPENAI_BASE_URL,
},
});
},
},
],
})
export class AiModule {}service 里直接注入:
@Injectable()
export class AiService {
private readonly chain: Runnable;
constructor(
@Inject('CHAT_MODEL') private readonly model: ChatOpenAI,
) {
const prompt = PromptTemplate.fromTemplate('请回答以下问题:\n\n{query}');
this.chain = prompt.pipe(model).pipe(new StringOutputParser());
}
}这样就实现了 ChatModel 和业务逻辑的解耦,可以动态切换模型。
这节我们学了 Nest + LangChain 来开发 AI 接口:
@Injectable 声明的 Service、通过 useFactory 创建的对象,都可以作为 provider 来注入@Inject 的方式)SSE 在 AI 接口流式返回内容方面是最常用的方式,后面会经常用到。
预览到此为止,输入密码解锁全文
解锁后本机会记住,同密码的其他文章也无需重复输入