1. 引言
随着大语言模型能力的快速提升,越来越多的业务场景开始引入 AI Agent 来承担复杂的任务编排与自动化工作。然而,在实际落地过程中,我们常常会遇到几个棘手的问题:Agent 的决策过程不可控、状态管理混乱、难以与现有后端服务集成。
本文将介绍如何用 TypeScript、NestJS 和 LangGraph 搭建一个可控的 AI Agent。NestJS 提供模块化的后端架构与依赖注入能力,LangGraph 则负责 Agent 的状态机编排与流程控制,TypeScript 让整个项目具备类型安全。三者结合,可以构建出既灵活又可控的 Agent 服务。
2. 技术选型与核心概念
2.1 为什么选择 LangGraph
LangGraph 是 LangChain 团队推出的一个用于构建有状态、可编排的 Agent 的框架。与传统的 ReAct 循环相比,LangGraph 的核心优势在于:
- 显式的状态机:节点(Node)与边(Edge)构成有向图,流程清晰可控。
- 内置状态管理:每个节点可以读写共享状态,支持类型化。
- 可中断与恢复:支持 checkpoint,可以在任意节点暂停和恢复执行。
- 人工介入:可以在关键节点设置中断,等待人工审批或输入。
2.2 为什么选择 NestJS
NestJS 是 Node.js 生态中成熟的企业级框架,提供了:
- 模块化架构,便于组织 Agent 相关的服务与控制器。
- 依赖注入,方便替换 LLM 客户端、向量库等组件。
- 丰富的生态集成(如
@nestjs/axios、@nestjs/config)。 - 与 LangGraph 的
langgraphnpm 包无缝配合。
2.3 整体架构
#mermaid-svg-ofl1HhbZLFQXZPhs{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ofl1HhbZLFQXZPhs .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ofl1HhbZLFQXZPhs .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ofl1HhbZLFQXZPhs .error-icon{fill:#552222;}#mermaid-svg-ofl1HhbZLFQXZPhs .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ofl1HhbZLFQXZPhs .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ofl1HhbZLFQXZPhs .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ofl1HhbZLFQXZPhs .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ofl1HhbZLFQXZPhs .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ofl1HhbZLFQXZPhs .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ofl1HhbZLFQXZPhs .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ofl1HhbZLFQXZPhs .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ofl1HhbZLFQXZPhs .marker.cross{stroke:#333333;}#mermaid-svg-ofl1HhbZLFQXZPhs svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ofl1HhbZLFQXZPhs p{margin:0;}#mermaid-svg-ofl1HhbZLFQXZPhs .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-ofl1HhbZLFQXZPhs .cluster-label text{fill:#333;}#mermaid-svg-ofl1HhbZLFQXZPhs .cluster-label span{color:#333;}#mermaid-svg-ofl1HhbZLFQXZPhs .cluster-label span p{background-color:transparent;}#mermaid-svg-ofl1HhbZLFQXZPhs .label text,#mermaid-svg-ofl1HhbZLFQXZPhs span{fill:#333;color:#333;}#mermaid-svg-ofl1HhbZLFQXZPhs .node rect,#mermaid-svg-ofl1HhbZLFQXZPhs .node circle,#mermaid-svg-ofl1HhbZLFQXZPhs .node ellipse,#mermaid-svg-ofl1HhbZLFQXZPhs .node polygon,#mermaid-svg-ofl1HhbZLFQXZPhs .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ofl1HhbZLFQXZPhs .rough-node .label text,#mermaid-svg-ofl1HhbZLFQXZPhs .node .label text,#mermaid-svg-ofl1HhbZLFQXZPhs .image-shape .label,#mermaid-svg-ofl1HhbZLFQXZPhs .icon-shape .label{text-anchor:middle;}#mermaid-svg-ofl1HhbZLFQXZPhs .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-ofl1HhbZLFQXZPhs .rough-node .label,#mermaid-svg-ofl1HhbZLFQXZPhs .node .label,#mermaid-svg-ofl1HhbZLFQXZPhs .image-shape .label,#mermaid-svg-ofl1HhbZLFQXZPhs .icon-shape .label{text-align:center;}#mermaid-svg-ofl1HhbZLFQXZPhs .node.clickable{cursor:pointer;}#mermaid-svg-ofl1HhbZLFQXZPhs .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-ofl1HhbZLFQXZPhs .arrowheadPath{fill:#333333;}#mermaid-svg-ofl1HhbZLFQXZPhs .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-ofl1HhbZLFQXZPhs .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-ofl1HhbZLFQXZPhs .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ofl1HhbZLFQXZPhs .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-ofl1HhbZLFQXZPhs .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ofl1HhbZLFQXZPhs .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-ofl1HhbZLFQXZPhs .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-ofl1HhbZLFQXZPhs .cluster text{fill:#333;}#mermaid-svg-ofl1HhbZLFQXZPhs .cluster span{color:#333;}#mermaid-svg-ofl1HhbZLFQXZPhs div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-ofl1HhbZLFQXZPhs .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ofl1HhbZLFQXZPhs rect.text{fill:none;stroke-width:0;}#mermaid-svg-ofl1HhbZLFQXZPhs .icon-shape,#mermaid-svg-ofl1HhbZLFQXZPhs .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ofl1HhbZLFQXZPhs .icon-shape p,#mermaid-svg-ofl1HhbZLFQXZPhs .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-ofl1HhbZLFQXZPhs .icon-shape .label rect,#mermaid-svg-ofl1HhbZLFQXZPhs .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ofl1HhbZLFQXZPhs .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-ofl1HhbZLFQXZPhs .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-ofl1HhbZLFQXZPhs :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 否
是
HTTP 请求进入 NestJS Controller
AgentService 创建会话
LangGraph 状态机初始化
是否需要人工介入?
Agent 节点循环执行
返回最终结果
挂起等待人工输入
恢复执行
3. 环境准备与项目初始化
3.1 初始化 NestJS 项目
bash
# 使用 Nest CLI 创建项目
nest new ai-agent-demo
# 进入项目目录
cd ai-agent-demo
3.2 安装依赖
bash
# 安装 LangGraph 与 LangChain 相关依赖
npm install langgraph @langchain/core @langchain/langgraph
# 安装 OpenAI 模型接入(可按需替换为其他模型)
npm install @langchain/openai
# 安装校验与配置依赖
npm install class-validator class-transformer
3.3 配置环境变量
在项目根目录创建 .env 文件:
env
OPENAI_API_KEY=your-api-key-here
OPENAI_MODEL=gpt-4o-mini
4. 定义 Agent 的状态与节点
4.1 定义状态类型
LangGraph 的状态是一个可累加的对象。我们使用 Annotation 来定义状态的结构:
typescript
// src/agent/agent.state.ts
import { Annotation } from "@langchain/langgraph";
export const AgentState = Annotation.Root({
// 用户输入的消息
messages: Annotation<string[]>({
reducer: (current, incoming) => current.concat(incoming),
default: () => [],
}),
// 当前是否等待人工介入
interruptFlag: Annotation<boolean>({
reducer: (current, incoming) => incoming ?? current,
default: () => false,
}),
// 最终输出结果
finalAnswer: Annotation<string>({
reducer: (current, incoming) => incoming ?? current,
default: () => "",
}),
});
export type AgentStateType = typeof AgentState.State;
4.2 实现 Agent 节点
节点是 LangGraph 中的基本执行单元。每个节点接收当前状态,执行逻辑后返回状态的部分更新:
typescript
// src/agent/agent.nodes.ts
import { AgentStateType } from "./agent.state";
// 节点 1:调用 LLM 生成回复
export async function callModel(state: AgentStateType) {
// 这里可以调用 LangChain 的 ChatModel
// 为简化示例,我们直接返回一条固定消息
const response = `已收到你的消息:${state.messages.at(-1)}`;
return {
messages: [response],
};
}
// 节点 2:判断是否需要人工介入
export async function checkInterrupt(state: AgentStateType) {
const lastMessage = state.messages.at(-1) ?? "";
// 当用户消息包含"人工"或"审批"时,触发人工介入
const needInterrupt = /人工|审批/.test(lastMessage);
return {
interruptFlag: needInterrupt,
};
}
// 节点 3:生成最终答案
export async function finalize(state: AgentStateType) {
return {
finalAnswer: state.messages.at(-1) ?? "无结果",
};
}
5. 组装 LangGraph 状态机
5.1 创建图并连接节点
typescript
// src/agent/agent.graph.ts
import { StateGraph, START, END } from "@langchain/langgraph";
import { AgentState } from "./agent.state";
import { callModel, checkInterrupt, finalize } from "./agent.nodes";
export async function buildAgentGraph() {
const graph = new StateGraph(AgentState)
// 注册节点
.addNode("callModel", callModel)
.addNode("checkInterrupt", checkInterrupt)
.addNode("finalize", finalize)
// 定义流转:从入口到 callModel
.addEdge(START, "callModel")
.addEdge("callModel", "checkInterrupt")
// 条件边:根据 interruptFlag 决定走向
.addConditionalEdges("checkInterrupt", (state) => {
return state.interruptFlag ? "humanInterrupt" : "finalize";
})
// 人工介入节点(这里用 finalize 占位,实际可挂起)
.addEdge("humanInterrupt", "finalize")
.addEdge("finalize", END);
// 编译图
return graph.compile();
}
5.2 在 NestJS 中封装为 Provider
typescript
// src/agent/agent.module.ts
import { Module } from "@nestjs/common";
import { AgentService } from "./agent.service";
@Module({
providers: [AgentService],
exports: [AgentService],
})
export class AgentModule {}
typescript
// src/agent/agent.service.ts
import { Injectable, Logger } from "@nestjs/common";
import { buildAgentGraph } from "./agent.graph";
@Injectable()
export class AgentService {
private readonly logger = new Logger(AgentService.name);
private graph: Awaited<ReturnType<typeof buildAgentGraph>>;
async onModuleInit() {
this.graph = await buildAgentGraph();
this.logger.log("Agent 图已编译完成");
}
async run(input: string) {
const result = await this.graph.invoke({
messages: [input],
});
return {
answer: result.finalAnswer,
messages: result.messages,
};
}
}
6. 暴露 HTTP 接口
6.1 创建 DTO 与控制器
typescript
// src/agent/dto/run-agent.dto.ts
import { IsNotEmpty, IsString } from "class-validator";
export class RunAgentDto {
@IsString()
@IsNotEmpty()
message: string;
}
typescript
// src/agent/agent.controller.ts
import { Body, Controller, Post } from "@nestjs/common";
import { AgentService } from "./agent.service";
import { RunAgentDto } from "./dto/run-agent.dto";
@Controller("agent")
export class AgentController {
constructor(private readonly agentService: AgentService) {}
@Post("run")
async run(@Body() dto: RunAgentDto) {
return this.agentService.run(dto.message);
}
}
6.2 注册模块
typescript
// src/app.module.ts
import { Module } from "@nestjs/common";
import { AgentModule } from "./agent/agent.module";
@Module({
imports: [AgentModule],
})
export class AppModule {}
7. 实现人工介入(Human-in-the-loop)
LangGraph 的 interrupt 函数可以在节点中挂起执行,等待外部恢复:
typescript
// src/agent/agent.nodes.ts
import { interrupt } from "@langchain/langgraph";
// 人工审批节点
export async function humanApproval(state: AgentStateType) {
const lastMessage = state.messages.at(-1) ?? "";
// 挂起执行,等待人工输入
const userDecision = interrupt({
type: "approval",
content: lastMessage,
});
return {
messages: [`人工审批结果:${JSON.stringify(userDecision)}`],
};
}
在服务层使用 invoke 配合 Command 恢复执行:
typescript
// src/agent/agent.service.ts
import { Command } from "@langchain/langgraph";
// 首次执行,遇到 interrupt 会抛出中断
const config = { configurable: { thread_id: "session-123" } };
try {
await this.graph.invoke({ messages: [input] }, config);
} catch (e) {
// 捕获中断,等待人工输入
}
// 人工输入后恢复
await this.graph.invoke(
new Command({ resume: { approved: true } }),
config
);
8. 完整调用流程
LLM 模型 LangGraph NestJS Controller 客户端 LLM 模型 LangGraph NestJS Controller 客户端 #mermaid-svg-GrCkzyES8optgxMf{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-GrCkzyES8optgxMf .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-GrCkzyES8optgxMf .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-GrCkzyES8optgxMf .error-icon{fill:#552222;}#mermaid-svg-GrCkzyES8optgxMf .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-GrCkzyES8optgxMf .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-GrCkzyES8optgxMf .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-GrCkzyES8optgxMf .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-GrCkzyES8optgxMf .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-GrCkzyES8optgxMf .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-GrCkzyES8optgxMf .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-GrCkzyES8optgxMf .marker{fill:#333333;stroke:#333333;}#mermaid-svg-GrCkzyES8optgxMf .marker.cross{stroke:#333333;}#mermaid-svg-GrCkzyES8optgxMf svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-GrCkzyES8optgxMf p{margin:0;}#mermaid-svg-GrCkzyES8optgxMf .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-GrCkzyES8optgxMf text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-GrCkzyES8optgxMf .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-GrCkzyES8optgxMf .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-GrCkzyES8optgxMf .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-GrCkzyES8optgxMf .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-GrCkzyES8optgxMf #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-GrCkzyES8optgxMf .sequenceNumber{fill:white;}#mermaid-svg-GrCkzyES8optgxMf #sequencenumber{fill:#333;}#mermaid-svg-GrCkzyES8optgxMf #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-GrCkzyES8optgxMf .messageText{fill:#333;stroke:none;}#mermaid-svg-GrCkzyES8optgxMf .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-GrCkzyES8optgxMf .labelText,#mermaid-svg-GrCkzyES8optgxMf .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-GrCkzyES8optgxMf .loopText,#mermaid-svg-GrCkzyES8optgxMf .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-GrCkzyES8optgxMf .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-GrCkzyES8optgxMf .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-GrCkzyES8optgxMf .noteText,#mermaid-svg-GrCkzyES8optgxMf .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-GrCkzyES8optgxMf .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-GrCkzyES8optgxMf .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-GrCkzyES8optgxMf .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-GrCkzyES8optgxMf .actorPopupMenu{position:absolute;}#mermaid-svg-GrCkzyES8optgxMf .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-GrCkzyES8optgxMf .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-GrCkzyES8optgxMf .actor-man circle,#mermaid-svg-GrCkzyES8optgxMf line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-GrCkzyES8optgxMf :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} alt需要人工介入 POST /agent/runinvoke({ messages })调用模型生成回复返回回复判断是否需要人工介入抛出中断返回待审批状态提交审批结果Command.resume 恢复返回最终结果返回 answer
9. 总结
本文从零开始,演示了如何用 TypeScript、NestJS 和 LangGraph 搭建一个可控的 AI Agent。核心要点如下:
- LangGraph 提供状态机编排,让 Agent 的每一步流转都清晰可见、可控可测。
- NestJS 提供企业级后端能力,模块化组织 Agent 逻辑,方便与现有系统集成。
- 人工介入机制让 Agent 在关键节点可以暂停等待审批,避免完全失控的自动执行。
在此基础上,你还可以进一步扩展:接入工具调用(Tool Calling)、增加记忆持久化、接入流式输出等。希望本文能为你构建生产级 AI Agent 提供参考。