Nest.js 在 AI 链路里
框架不决定 AI 能力。Nest.js 不会让你的检索更准,也不会让流式更快——它决定的是多人协作时代码会不会长歪。
所以这篇不讨论"用 Nest 能不能做 AI",而是讨论它在什么规模下值得,以及 AI 服务放在它的哪一层。
1. 它和 Express / Fastify 不是同一类东西
三个框架经常被放在一起比吞吐,然后得出"Nest 最慢"的结论。这个对比本身有问题:
- Express 是路由 + 中间件,刻意做得薄
- Fastify 是高性能 HTTP 服务器,schema 校验内置
- Nest 是应用框架,跑在 Express 或 Fastify 之上,补的是它们不提供的东西
Nest 提供的是:依赖注入容器、模块系统、Guard(鉴权)、Interceptor(横切逻辑)、Pipe(校验)、异常过滤器、以及开箱可用的测试模块。
所以"Nest 慢"的实际含义是:它为你换来的一套架构,代价是一部分吞吐和更重的启动。这个交换值不值,取决于你的团队和代码规模。
2. AI 服务里,DI 的真正价值
依赖注入在 AI 场景有一个特别具体的用处:把模型供应商做成可注入的实现。
// 定义接口
interface LlmProvider {
complete(req: CompletionRequest): Promise<CompletionResult>
stream(req: CompletionRequest): AsyncIterable<string>
}
// 生产实现
@Injectable()
class AnthropicProvider implements LlmProvider { /* ... */ }
// 测试实现
@Injectable()
class FakeProvider implements LlmProvider {
async complete() { return { text: '固定回复', usage: { in: 10, out: 5 } } }
}
带来的好处有三个,都很实际:
测试不打真实 API。 这是 AI 功能能做单元测试的前提——否则每个测试都在花钱且不稳定(具体位置见《测试分层比覆盖率数字重要》)。
换供应商不改业务代码。 配合《多模型接入与降级链》里的路由,切换只发生在 provider 实现层。
降级实现可以注入。 熔断时的兜底实现也可以是同一个接口的实现。
3. Guard 与 Interceptor 的用处
AI 服务里有几件事是每个路由都需要的:鉴权、限流、预算检查、审计日志。
在 Express 里这些靠中间件链,但中间件是"扁平"的——哪个路由用了哪些中间件要靠人记住,且不好组合。
Nest 的 Guard(返回布尔,决定是否放行)和 Interceptor(包裹执行,能做前后处理)给了这些横切关注点一个明确的位置:
- Guard:鉴权、权限范围校验、预算是否耗尽
- Interceptor:请求日志、耗时统计、token 用量上报、trace 上下文注入
其中最后一项——trace 上下文注入——在 AI 服务里价值最高。见《给大模型链路装仪表》,你要把每次请求串到 trace 上,而 Interceptor 是做这件事最自然的位置。
4. 流式在 Nest 里要注意什么
Nest 默认用 Express 适配器,而 Express 的 res 对象配合流式有一个经典陷阱:任何把它当普通响应处理的中间件都会破坏流式。
具体表现和《流式链路上的缓冲点》里说的一样:压缩中间件、日志中间件读一遍响应体,流式就变成非流式。
要点:
- 流式路由直接操作原始响应对象或返回可读流,不走 JSON 序列化
- 给流式路由排除压缩与 body 解析类的中间件
- 设置
X-Accel-Buffering: no与Cache-Control: no-cache, no-transform - 把客户端断连信号传给上游:自己建
AbortController,监听res.on('close')并用res.writableFinished区分正常结束——req.signal在 Node 的IncomingMessage上通常不存在,传undefined给 fetch 不会报错,只会让取消静默失效
Nest 其实内置了一个专门给 SSE 用的装饰器,很多人不知道:
@Sse('stream')
stream(@Query('q') q: string): Observable<MessageEvent> {
return from(this.llm.stream(q)).pipe(
map((chunk) => ({ data: chunk, type: 'delta' })),
)
}
@Sse() 会替你设好 Content-Type: text/event-stream,并把返回的 Observable 按事件帧格式写出去(data / id / type / retry 四个字段)。适合"事件结构规整"的场景;但它不处理背压,也拿不到最细的写控制,所以要么用它、要么走原始响应,别两套混着写。
顺带一句 Guard 里的限流:位置是对的,但 @nestjs/throttler 默认把计数放在进程内存里,多实例部署等于每个实例各限一份。要么换成 Redis 存储,要么统一收口到网关。
如果吞吐是真需求,可以切到 Fastify 适配器——Nest 官方支持,切换后仍保留 DI 和模块系统。这是"要 Nest 的结构 + 要 Fastify 的性能"的标准做法。
5. 什么时候不该用
说清楚边界比说优点重要:
小服务不该用。 三个接口的服务上 Nest,结构是纯负担。三个 endpoint 不需要模块系统。
serverless 要谨慎。 DI 容器和模块初始化带来真实的冷启动开销。在 Lambda 这类按冷启动计费的环境里,这个代价会被放大。已有对比结论普遍认为:serverless 场景更适合 Express/Fastify/Hono 这类轻量方案。
BFF 通常是过度设计。 见《BFF 该聚合,不该写业务》,BFF 应该薄。Nest 的全套结构在 BFF 上通常没必要——除非你的 BFF 已经长成了一个有几十个接口的层。
原型阶段。 需要快速验证想法时,强制的结构会拖慢速度。
判断标准很朴素:团队人数 × 代码预期寿命。几个人、要活几年的服务,Nest 划算;一个人、三个接口,不划算。
6. 和 FastAPI 怎么选
这一条其实就是《AI 后端选 Python 还是 Node》的延伸:
如果核心是检索与编排——Python 生态领先半年以上,Nest 补不回这个差距。
如果核心是给前端提供 AI 接口、且团队是 TypeScript——Nest 是 Node 侧最像"正经后端框架"的选择,尤其当服务会变大时。
两者都有的分层架构里——Nest 常常承担 BFF/网关那一层,而不是推理层。它的结构优势在"聚合多个下游、统一鉴权与限流"这件事上正好用得上。
7. AI 相关的模块怎么划
一个实用的模块划分:
LlmModule // provider 抽象与实现(可替换、可降级)
RetrievalModule // 检索服务(向量库、过滤、重排)
PromptModule // 提示词模板与版本
ChatModule // 会话编排(串起上面三个)
ObservabilityModule // trace、用量、审计(全局)
其中 PromptModule 值得单独拎出来。把提示词当代码管理(版本化、可测试、可 review)是 AI 服务里最重要的一条工程纪律之一,而模块化为它提供了一个自然的位置。
ObservabilityModule 应该是全局的,不要只在 ChatModule 里埋点——否则将来加了第二个 AI 功能,又要重做一遍。
8. 一个常见错误:把编排逻辑写进 Controller
Nest 的结构容易让人把业务逻辑堆在 Controller 里(因为它就在那里,方便)。
AI 服务里这会特别痛:Controller 里出现"先检索再拼提示词再调模型再解析结果",意味着这段逻辑无法被单独测试、无法被第二个接口复用、也无法换实现。
正确的位置是 Service 层,Controller 只做参数校验与响应组装。
判断方法:Controller 里如果出现任何"顺序"(先做什么后做什么),那就是编排逻辑放错了地方。
9. 清单
- 确认规模值得:团队 ≥5 人 / 接口数会增长 / 要活几年
- LLM provider 做成可注入接口,有测试实现
- 鉴权、限流、预算放在 Guard
- 日志、耗时、token 上报、trace 注入放在 Interceptor
- 流式路由排除了会缓冲的中间件
- 编排逻辑在 Service,不在 Controller
- 提示词单独成模块,版本化
- 吞吐是瓶颈时切 Fastify 适配器
- serverless 场景重新评估是否值得
10. 一句话
Nest.js 不解决 AI 的任何一个技术问题。它解决的是"五个工程师一起写这个服务,一年后代码还能不能看懂"。
这个问题在小项目里不存在,在真项目里迟早会出现。它的价值就在这里——不多,也不少。