一. 多 agent是什么?
多
Agent不是"多找几个AI开会",而是"给每个AI一个干净的上下文"。
多Agent的核心价值是"隔离",不是"协作"。
1.1 一个agent的坏处
一个 Agent 干所有事,它的上下文里会同时堆着:
- 系统提示词
10个工具的JSON Schema- 前
20轮对话历史 - 中间产出的草稿、报错、试错记录
这些信息互相干扰。典型症状:工具越多,选错的概率越高;对话越长,越容易忘记最初的目标。 这不是模型笨,是注意力被稀释了。
1.2 多agent常见的架构
1.2.1 主管 - 工人(Supervisor / Orchestrator)。
一个主 Agent 负责任务分解和派活,子 Agent 各自干活、互不干扰,结果汇总回主 Agent。
主agent会将任务划分成三个,然后分别交给三个子agent去做,三个子agent拿到对应的结果以后,主agent就开始汇总信息,把最终答案交给用户。
js
主管:拆成 A、B、C 三个子任务
├─ 子Agent A(只有搜索工具 + 搜索相关的 prompt)
├─ 子Agent B(只有代码工具 + 代码相关的 prompt) → 并行
└─ 子Agent C(只有数据分析工具 + 分析 prompt)
主管:汇总三个结果,产出最终答案
1.2.3 流水线(Pipeline / Assembly Line)
上一个的输出是下一个的输入,像工厂流水线。每个环节一个 Agent,职责单一、可单独调优、可单独替换。比如写小说的agent
js
选题 → 大纲 → 初稿 → 审校 → 定稿
5. 3. 生成器 - 评审(Generator / Critic)
一个生成,一个挑毛病,来回迭代,这个并不推荐。
1.3 什么时候可以使用多agent?
- 上下文真的装不下了 ------ 工具超过
8-10个,或单次任务信息量超过100K token - 任务可以真正并行 ------ 多个独立子任务,串行做太慢
- 需要不同的人格/权限 ------ 比如一个
Agent只读、一个可写;一个面向用户、一个做内部分析
除此之外,优先优化单 Agent 的 prompt 和工具描述,性价比高得多。
1.4技术栈的支持程度
你前面在看 AG-UI 和 Vercel,这两家对多 Agent 的支持程度不一样:
- AG-UI :有
parentRunId字段,协议层就支持子 Agent 事件挂在父 run 下,天然适合多 Agent 树。但多 Agent 编排本身靠RAW和CUSTOM事件扩展,协议没规定怎么编排。 - Vercel AI SDK :有
start-step/finish-step做步骤边界,但没有原生的 Agent 树概念,扁平的。
真要落地多 Agent,编排逻辑(谁派给谁、怎么汇总)得靠 LangGraph、或自己写状态机------这两个协议都只管传输,不管编排。
1.4 多agent的好处

1.5 agent对比
线性编排 agent 用 langchain 实现,典型结构如:LCEL。
多 agent 用 langgraph实现,LangGraph 是LangChain团队开发的开源图编排框架
与LangChain链式结构不同,LangGraph原生支持多智能体协作模式(如主管/移交/群体模式),适用于客服自动化、代码测试生成等需要状态跟踪的场景。

使用langgraph的场景
案例 1:自我纠错写作 Agent ⭐⭐⭐(入门首选)
场景:让 AI 写一篇文章,另一个 AI 打分,不合格就打回重写,最多 3 次。
案例 2:并行研究 + 汇总(多 Agent)⭐⭐⭐
场景:给一个主题,同时派 3 个子 Agent 分别查"技术现状""市场数据""竞品分析",最后汇总成报告。
案例 3:带人工审批的合同审核 ⭐⭐⭐⭐
场景 :AI 分析合同风险 → 发现高风险条款 → 暂停,等人点"同意/驳回" → 根据选择走不同分支。
案例 4:可回溯的代码调试 Agent ⭐⭐⭐⭐⭐(杀手锏)
场景 :AI 写代码 → 跑测试 → 失败就自己改 → 改不动了回滚到上一版换个思路重来。
二. 图编排框架-Langgraph
2.1创建项目
js
nest new langgraph-test

2.2 简单图编引擎
执行命令
js
pnpm install @langchain/langgraph @langchain/core @langchain/openai dotenv zod
pnpm install @nestjs/config
新建.env,配置常量。
js
OPENAI_API_KEY=sk-xxx
OPENAI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
MODEL_NAME=qwen-plus
app.module.ts里面全局注册 
创建ai模块
js
nest g resource ai --no-spec
2.3 实现代码
实现一个调用langgraph的基础案例,
实现ai.graph.ts
js
import { Annotation, END, START, StateGraph } from '@langchain/langgraph';
export const StateAnnotation = Annotation.Root({
text: Annotation<string>({
reducer: (a, b) => a + b,
default: () => '',
}),
});
export type GraphState = typeof StateAnnotation.State;
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
/**
* 2. 定义节点 Node
* 节点 = 普通函数:接收当前 state,返回"局部补丁"(不是完整 state)
* sleep 只是为了让你看清流式效果,实际项目删掉
*/
const step1 = async (state: GraphState) => {
await sleep(600);
return {
text: `\n[step1] 原文「${state.text}」,转大写 → ${state.text.toUpperCase()}`,
};
};
const step2 = async (state: GraphState) => {
await sleep(600);
return { text: `\n[step2] 文本长度:${state.text.length} 个字符` };
};
/**
* 3. 定义边 Edge
* addEdge 是无条件跳转;真 Agent 用 addConditionalEdges 做条件分支和循环
*/
const builder = new StateGraph(StateAnnotation)
.addNode('step1', step1)
.addNode('step2', step2)
.addEdge(START, 'step1')
.addEdge('step1', 'step2')
.addEdge('step2', END);
/**
* 4. 编译 ------ 没编译的图不能跑
* compile() 产出的 graph 才是能 invoke / stream / 中断恢复的可执行对象
*/
export const graph = builder.compile();
/** 5. 导出 Mermaid(副产品,粘贴到 mermaid.live 可看流程图) */
export const getMermaid = () => graph.getGraph().drawMermaid();
他里面的流程图是这样的

第一步转换成大写,第二步显示有多少字节。
实现ai.service.ts
js
import { Injectable } from '@nestjs/common';
import { graph, getMermaid, GraphState } from './ai.graph.js';
@Injectable()
export class AiService {
async *stream(text: string) {
const stream = await graph.stream({ text }, { streamMode: 'updates' });
for await (const chunk of stream) {
// chunk 形如 { step1: { text: '...' } }
for (const [node, patch] of Object.entries(
chunk as Record<string, any>,
)) {
yield { node, patch, done: false };
}
}
yield { node: '__end__', patch: null, done: true };
}
}
实现 ai.controller.ts
js
import {
Controller,
Sse,
Query,
MessageEvent,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { AiService } from './ai.service.js';
@Controller('ai')
export class AiController {
constructor(private readonly aiService: AiService) {}
/**
* 流式:GET /ai/chat?text=hello
*
* @Sse 要求返回 Observable<MessageEvent>
* 前端用 new EventSource('/ai/chat?text=hello') 接收
*
* ⚠️ SSE 是单向的(服务端→客户端)。
* 如果你要做 human-in-the-loop(前端点"批准"再继续),
* 得换成 @WebSocketGateway + @SubscribeMessage
*/
@Sse('chat')
chat(@Query('text') text = 'hello'): Observable<MessageEvent> {
return new Observable((subscriber) => {
(async () => {
try {
for await (const evt of this.aiService.stream(text)) {
subscriber.next({ data: JSON.stringify(evt) });
}
} catch (err) {
subscriber.next({ data: JSON.stringify({ error: String(err) }) });
} finally {
subscriber.complete();
}
})();
});
}
}
实现 index.html
js
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>LangGraph SSE 测试</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
max-width: 720px;
margin: 40px auto;
padding: 0 20px;
}
.row {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
input {
flex: 1;
padding: 10px 12px;
font-size: 14px;
border: 1px solid #d0d0d0;
border-radius: 6px;
outline: none;
}
input:focus {
border-color: #4a90d9;
}
button {
padding: 10px 20px;
font-size: 14px;
background: #4a90d9;
color: #fff;
border: none;
border-radius: 6px;
cursor: pointer;
}
button:hover {
background: #3a7bc8;
}
button:disabled {
background: #a8c5e8;
cursor: not-allowed;
}
#log {
background: #1e1e1e;
color: #d4d4d4;
padding: 16px;
border-radius: 6px;
font-family: Consolas, Monaco, monospace;
font-size: 13px;
line-height: 1.7;
white-space: pre-wrap;
min-height: 120px;
}
.done {
color: #4ec9b0;
}
.node {
color: #dcdcaa;
}
.err {
color: #f48771;
}
.info {
color: #808080;
}
</style>
</head>
<body>
<h2>LangGraph SSE 测试</h2>
<div class="row">
<input
id="textInput"
type="text"
placeholder="输入文本,例如 hello"
value="hello"
/>
<button id="sendBtn">发送</button>
</div>
<div id="log"></div>
<script>
const input = document.getElementById('textInput');
const button = document.getElementById('sendBtn');
const log = document.getElementById('log');
let es = null; // 保存当前连接,方便重复点击时关掉旧的
function print(text, cls) {
const span = document.createElement('span');
if (cls) span.className = cls;
span.textContent = text + '\n';
log.appendChild(span);
log.scrollTop = log.scrollHeight;
}
button.addEventListener('click', () => {
const text = input.value.trim();
if (!text) {
print('⚠️ 请输入文本', 'err');
return;
}
// 重复点击时先关掉上一个连接
if (es) es.close();
log.innerHTML = '';
button.disabled = true;
print('🔗 正在连接...', 'info');
// 你给的那段代码,只是把 hello 换成了 input 的值
es = new EventSource(
'http://localhost:3000/ai/chat?text=' + encodeURIComponent(text),
);
es.onmessage = (e) => {
const data = JSON.parse(e.data);
if (data.done) {
print('✅ 完成', 'done');
es.close();
es = null;
button.disabled = false;
} else {
console.log(`[${data.node}]`, data.patch); // 保留你原本的 console 输出
print(`[${data.node}] ` + JSON.stringify(data.patch), 'node');
}
};
es.onerror = () => {
print('❌ 连接失败或已断开(确认后端 3000 端口已启动)', 'err');
es.close();
es = null;
button.disabled = false;
};
});
// 回车发送
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') button.click();
});
</script>
</body>
</html>
测试

如果你想知道你langgraph的流程图到底是什么样的,就在ai.graph.ts里面添加

然后在service里面写一个方法,在controller里面写一个对应的接口

然后在浏览器请求,复制响应数据。

进入:mermaid.live/edit#pako:e...
把响应数据放到左侧,右侧就会出现对应的流程图。

2.3 条件图编引擎
只需要添加一个graph文件就好了

实现 ai.graph.ts
js
import { Annotation, END, START, StateGraph } from '@langchain/langgraph';
/**
* 1. 定义状态 State ------ 流经整张图的那份数据
*
* reducer 决定"节点返回的补丁如何合并回状态":
* (a, b) => a + b → 累加(本例:hello + step1 + step2 拼在一起)
* 不写 reducer → 直接覆盖(step2 返回什么,text 就变成什么)
* ⚠️ 90% 的 LangGraph 状态 bug 都出在 reducer 上
*/
export const StateAnnotation = Annotation.Root({
query: Annotation<string>({
reducer: (_prev, next) => next,
default: () => '',
}),
route: Annotation<string>({
reducer: (_prev, next) => next,
default: () => 'chat',
}),
answer: Annotation<string>({
reducer: (_prev, next) => next,
default: () => '',
}),
});
const router = (state: any) => {
const isMath = /[+\-*/]/.test(state.query);
return { route: isMath ? 'math' : 'chat' };
};
const mathMode = (state: any) => {
try {
return { answer: String(eval(state.query)) };
} catch (err) {
return { answer: '表达式无法计算' };
}
};
const chatNode = (state: any) => ({ answer: `你说的是:${state.query}` });
const builder = new StateGraph(StateAnnotation)
.addNode('router', router)
.addNode('math', mathMode)
.addNode('chat', chatNode)
.addEdge(START, 'router')
.addConditionalEdges('router', (state) => state.route, {
math: 'math',
chat: 'chat',
})
.addEdge('math', END)
.addEdge('chat', END);
export const graphCondition = builder.compile();
const drawable = await graphCondition.getGraphAsync();
export const mermaidCondition = () =>
drawable.drawMermaid({ withStyles: true });
console.log(mermaidCondition);
console.log(await graphCondition.invoke({ query: '你好' }));
ai.service.ts 和 ai.controller.ts的代码

在上一个例子里面的index.html的接口改成下面这样

测试


当input里面是Math的时候,就执行math,计算给我答案。当input是其他字符串的时候,就执行chat,直接拼接:你说的是....


2.4 循环图编引擎
实现 ai-loop.graph.ts
js
import { Annotation, END, START, StateGraph } from '@langchain/langgraph';
import { AnyCatcher } from 'rxjs/internal/AnyCatcher';
const StateAnnotation = Annotation.Root({
tries: Annotation({
reducer: (_prev, next: number) => next,
default: () => 0,
}),
ok: Annotation({
reducer: (_prev, next: boolean) => next,
default: () => false,
}),
message: Annotation({
reducer: (_prev, next: string) => next,
default: () => '',
}),
});
const attempt = (state: any) => {
const tries = state.tries + 1;
const ok = tries >= 3;
return {
tries,
ok,
message: ok
? `第${tries}次成功`
: `第${tries}次失败,还剩${3 - tries}次机会`,
};
};
const builder = new StateGraph(StateAnnotation)
.addNode('attempt', attempt)
.addEdge(START, 'attempt')
.addConditionalEdges('attempt', (state) => (state.ok ? 'done' : 'retry'), {
retry: 'attempt',
done: END,
});
export const graphLoop = builder.compile();
const drawable = await graphLoop.getGraphAsync();
export const mermaidLoop = () => drawable.drawMermaid({ withStyles: true });
在 service 和 controller里面添上接口

在index.html里面添加接口

测试



2.5 数据存储
实现 ai-memory.grapth.ts
案例使用MemorySaver存储在内存里面,页面一刷新就会清除,一般在企业应用里面都是存储在数据库里面的。
js
import {
Annotation,
END,
MemorySaver,
START,
StateGraph,
} from '@langchain/langgraph';
const StateAnnotation = Annotation.Root({
visitCount: Annotation({
reducer: (_prev, next: any) => next,
default: () => 0,
}),
message: Annotation({
reducer: (_prev, next: any) => next,
default: () => '',
}),
});
function recordVisit(state: any) {
const visitCount = state.visitCount + 1;
const message =
visitCount === 1
? '这里是你第一次进入'
: '这是你的第' + visitCount + '次进入';
return { visitCount, message };
}
const graph = new StateGraph(StateAnnotation)
.addNode('recordVisit', recordVisit)
.addEdge(START, 'recordVisit')
.addEdge('recordVisit', END);
const checkpointer = new MemorySaver(); // 创建一个MemorySaver实例,用于保存状态。
export const graphMessage = graph.compile({ checkpointer }); //编译的时候将checkpointer传入,这样就可以保存状态了。
在service和controller里面

测试

2.6 确认之后继续执行
就是说整个流程有个中断的时候。比如确认转账的场景。
转账中,state有2个,一个是用户输入userinput,一个是后端返回的答案actionSummary
第一步中断,中断前后端信息会在下图这里对上


第二步中断确认后,前后端信息反馈


实现ai.interrupt.graph.ts
详细代码如下:
js
import {
Annotation,
END,
MemorySaver,
START,
StateGraph,
interrupt,
} from '@langchain/langgraph';
const StateAnnotation = Annotation.Root({
actionSummary: Annotation<string>({
reducer: (_p, n) => n,
default: () => '',
}),
userinput: Annotation<string>({ reducer: (_p, n) => n, default: () => '' }), // 全小写
});
const showTransfer = () => ({ actionSummary: '向张三转账 ¥100 元。' });
// ⚠️ 恢复时本节点会【整体重跑】,interrupt 之前不能有任何副作用
const waitConfirm = (state: any) => {
const text = interrupt({
hint: '请确认是否要执行转账。',
actionSummary: state.actionSummary,
});
return { userinput: String(text) }; // key 必须是 userinput
};
// 真正的转账放在确认之后,保证只执行一次
const doTransfer = (state: any) => ({
actionSummary:
state.userinput === 'yes'
? `${state.actionSummary} ------ 已执行成功。`
: `${state.actionSummary} ------ 已取消。`,
});
const build = () =>
new StateGraph(StateAnnotation)
.addNode('showTransfer', showTransfer)
.addNode('waitConfirm', waitConfirm)
.addNode('doTransfer', doTransfer)
.addEdge(START, 'showTransfer')
.addEdge('showTransfer', 'waitConfirm')
.addEdge('waitConfirm', 'doTransfer')
.addEdge('doTransfer', END)
.compile({ checkpointer: new MemorySaver() }); // 生产换 SqliteSaver
// 单例:避免 NestJS 热重载 / 多次求值把内存状态清空
const g = globalThis as any;
export const transferGraph = g.__transferGraph ?? (g.__transferGraph = build());
实现 ai.controller.ts
js
import {
Controller,
Sse,
Query,
MessageEvent,
Get,
Body,
Post,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { AiService } from './ai.service.js';
import { Command } from '@langchain/langgraph';
import { randomUUID } from 'node:crypto';
import { transferGraph } from './ai-comfirm-interrupt.js';
@Controller('ai')
export class AiController {
constructor(private readonly aiService: AiService) {}
@Post('start')
async start() {
const threadId = randomUUID(); // 后端生成,别让前端瞎编
const config = { configurable: { thread_id: threadId } };
const result: any = await transferGraph.invoke({}, config);
const hit = result.__interrupt__?.[0];
if (!hit) return { done: true, threadId, result };
return { needConfirm: true, threadId, payload: hit.value };
}
/** ② 确认:同一个 threadId + Command({ resume }) 从暂停处续跑 */
@Post('confirm')
async confirm(@Body() body: { threadId: string; confirmed: boolean }) {
const { threadId, confirmed } = body;
const config = { configurable: { thread_id: threadId } };
const result: any = await transferGraph.invoke(
new Command({ resume: confirmed ? 'yes' : 'no' }), // 值 = interrupt() 的返回
config,
);
const hit = result.__interrupt__?.[0];
if (hit) return { needConfirm: true, threadId, payload: hit.value }; // 多级确认
return { done: true, threadId, result };
}
}
测试



2.7 调用大模型和tool
需求:我有一个简单的数据库,里面存储了仓库现存产品的数据,我想要model帮我找到具体的存量。
实现mock数据
js
const rows = [
{ sku: 'SKU-001', name: '无线鼠标', stock: 45 },
{ sku: 'SKU-002', name: '机械键盘', stock: 89 },
{ sku: 'SKU-003', name: 'USB-C 线缆', stock: 10 },
];
export function getProductBySKU(sku: any) {
const key = String(sku).trim().toUpperCase();
const row = rows.find((r) => r.sku.toUpperCase() === key);
if (!row) {
return JSON.stringify({ found: false, sku: String(sku).trim() });
}
return JSON.stringify({ found: true, ...row });
}
实现ai-tool.graph.ts
正常来说,在nestjs项目里面除过utils之外,其他文件都应该放到class里面,现在为了测试方便,我直接不用class包装,直接在controller里面拿到使用,这做的结果就是在加载文件的时候就会去执行他。所以极其不推荐。
js
import { HumanMessage } from '@langchain/core/messages';
import { tool } from '@langchain/core/tools';
import {
END,
MessagesAnnotation,
START,
StateGraph,
} from '@langchain/langgraph';
import { ToolNode, toolsCondition } from '@langchain/langgraph/prebuilt';
import { ChatOpenAI } from '@langchain/openai';
import { z } from 'zod';
import { getProductBySKU } from './mock.js';
const getProductStock = tool(async ({ sku }) => getProductBySKU(sku), {
name: 'get_product_stock',
description: '按 SKU 查商品名与库存,SKU 如 SKU-001。',
schema: z.object({
sku: z.string().describe('商品 SKU'),
}),
});
const tools = [getProductStock];
const llm = new ChatOpenAI({
modelName: 'qwen-plus',
apiKey: 'sk-44252256df1545b9b3989c8a6dc8cc73',
configuration: {
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
},
}).bindTools(tools);
async function agent(state: any) {
const response = await llm.invoke(state.messages);
return { messages: response };
}
const toolNode = new ToolNode(tools);
const graph = new StateGraph(MessagesAnnotation)
.addNode('agent', agent)
.addNode('tools', toolNode)
.addEdge(START, 'agent')
.addConditionalEdges('agent', toolsCondition, ['tools', END])
.addEdge('tools', 'agent')
.compile();
const result = await graph.invoke({
messages: [
new HumanMessage('查一下 SKU-001 的库存还有多少,回答里带上商品名和数字。'),
],
});
const drawable = await graph.getGraphAsync();
const mermaid = drawable.drawMermaid({ withStyles: true });
console.log(mermaid);
const last = result.messages.at(-1);
console.log(last?.content ?? result.messages);
export const getModelProductStock = () => {
return last?.content ?? result.messages;
};
实现ai.controller.ts


测试

2.8 条件语句,循环,大模型共用案例
需求:在上个例子里面,如果我搜索价格,就通过价格tool给我具体数据。如果我搜索存量,就通过存量给我具体数据。如果我搜索所有数据,就帮我显示所有的数据的名称
实现ai-id-loop.graph.ts
代码将初始化graph的方法提到class 的外面,这个class就是和service对应的内容。AiGraphService类里面定义了可用的2个方法,一个是run,一个stream,我选择用stream实现流式输出。
js
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { ChatOpenAI } from '@langchain/openai';
import { StateGraph, Annotation, START, END } from '@langchain/langgraph';
import { tool } from '@langchain/core/tools';
import { HumanMessage, SystemMessage } from '@langchain/core/messages';
import { z } from 'zod';
import { getProductBySKU, getProductByPage } from './mock.js';
/* ==========================================================================
* 1. 三个工具:不依赖配置,留在模块顶层没问题
* ========================================================================== */
//数据库字段的描述,方便大模型理解,不依赖配置
const PRODUCT_FIELDS = `返回 JSON 字段含义:
- sku:商品唯一编码
- name:商品名称
- stock:可用库存数量,单位:件
- price:当前售价,单位:人民币元`;
const PAGE_SIZE = 1;
const queryStock = tool(async ({ sku }) => getProductBySKU(sku), {
name: 'query_stock',
description: `按 SKU 查询商品的库存。${PRODUCT_FIELDS}`,
schema: z.object({
sku: z.string().describe('商品 SKU 编码,形如 SKU-001'),
}),
});
const queryPrice = tool(async ({ sku }) => getProductBySKU(sku), {
name: 'query_price',
description: `按 SKU 查询商品的价格。${PRODUCT_FIELDS}`,
schema: z.object({
sku: z.string().describe('商品 SKU 编码,形如 SKU-001'),
}),
});
const fetchPage = tool(async ({ page }) => getProductByPage(page), {
name: 'fetch_page',
description:
`按页码分页抓取商品列表,每页 ${PAGE_SIZE} 条,page 从 0 开始。` +
`返回 JSON:found=是否有数据,page=当前页码,count=本页条数,` +
`hasMore=是否还有下一页,data=本页商品数组。${PRODUCT_FIELDS}`,
schema: z.object({
page: z.number().int().min(0).describe('页码,从 0 开始,每次调用 +1'),
}),
});
/* ==========================================================================
* 2. 状态定义
* ========================================================================== */
//state是用来定义状态,在图里流转的变量。
const State = Annotation.Root({
input: Annotation<string>,
condition: Annotation<'c1' | 'c2' | 'c3'>,
sku: Annotation<string>, // 上一轮抽出来的 SKU
result: Annotation<string>,
routeReason: Annotation<string>,
page: Annotation<number>({ reducer: (_, b) => b, default: () => 0 }), // ← 加回来
items: Annotation<string[]>({
reducer: (a, b) => [...a, ...b],
default: () => [],
}),
done: Annotation<boolean>({ reducer: (_, b) => b, default: () => false }),
loopCount: Annotation<number>({ reducer: (_, b) => b, default: () => 0 }),
data: Annotation<string>,
});
type GraphState = typeof State.State;
/* ==========================================================================
* 3. 工厂:模型 + 图,都延迟到拿到配置之后再建
* ========================================================================== */
function buildGraph(model: ChatOpenAI) {
const routeSchema = z.object({
condition: z
.enum(['c1', 'c2', 'c3'])
.describe(
'c1=单一商品库存查询;c2=单一商品价格查询;c3=需要分页遍历全部数据',
),
reason: z.string().describe('一句话说明判断依据'),
sku: z
.string()
.describe(
'从用户问题里提取的 SKU 编码,形如 SKU-001;如果用户问的是全部商品、没有指定某个 SKU,填空字符串',
),
});
const router = async (state: GraphState) => {
const res = await model
.withStructuredOutput(routeSchema)
.invoke([
new SystemMessage(
'你是意图分类器。根据用户问题判断走哪条分支,只输出分类结果,不要回答问题。\n' +
'判断规则:\n' +
'- 用户指定了某个 SKU,问库存/数量 → c1\n' +
'- 用户指定了某个 SKU,问价格/多少钱 → c2\n' +
'- 用户问的是全部商品、所有商品、列出清单、共几种 → c3\n\n' +
'示例:\n' +
'"SKU-001 还有多少库存" → c1\n' +
'"SKU-002 多少钱" → c2\n' +
'"把所有商品都列出来" → c3\n' +
'"商品清单" → c3',
),
new HumanMessage(state.input),
]);
const sku =
res.sku?.trim() ||
(state.input.toUpperCase().match(/SKU[-_]?\d+/i)?.[0] ?? '');
return { condition: res.condition, sku, result: res.reason };
};
const nodeA = async (state: GraphState) => ({
data: await queryStock.invoke({ sku: state.sku || state.input }),
});
const nodeB = async (state: GraphState) => ({
data: await queryPrice.invoke({ sku: state.sku || state.input }),
});
const nodeC = async (state: GraphState) => {
// ① 传页码,不是整句话
const raw = await fetchPage.invoke({ page: state.page });
// ② 解析出 hasMore,作为循环终止依据
let parsed: {
found: boolean;
count: number;
hasMore: boolean;
data: unknown[];
};
try {
parsed = JSON.parse(raw);
} catch {
// 工具返回异常时立即停止,避免死循环
return {
loopCount: state.loopCount + 1,
done: true,
page: state.page + 1,
};
}
return {
// ③ 只把本页的 data 追加进 items(reducer 负责累加)
items: parsed.count > 0 ? [JSON.stringify(parsed.data)] : [],
page: state.page + 1, // ④ 页码递增,下一轮拉新的一页
loopCount: state.loopCount + 1,
done: !parsed.hasMore, // ⑤ 没下一页了就停
};
};
const afterC = (state: GraphState) => {
if (state.done) return 'finalize';
if (state.loopCount >= 20) return 'finalize'; // 安全阀,保留
return 'nodeC';
};
const finalize = async (state: GraphState) => {
const raw = state.items.length
? state.items.map((s, i) => `第 ${i + 1} 页:${s}`).join('\n')
: (state.data ?? '(工具无返回)');
const res = await model.invoke([
new SystemMessage(
'你是电商数据助手。只能基于【工具返回】里的原始数据回答,不得编造任何数字;' +
'若数据不足,直接说明缺失什么。回答简洁,用中文。',
),
new HumanMessage(`用户问题:${state.input}\n\n【工具返回】\n${raw}`),
]);
return { result: res.content as string };
};
return new StateGraph(State)
.addNode('router', router)
.addNode('nodeA', nodeA)
.addNode('nodeB', nodeB)
.addNode('nodeC', nodeC)
.addNode('finalize', finalize)
.addEdge(START, 'router')
.addConditionalEdges('router', (s: GraphState) => s.condition, {
c1: 'nodeA',
c2: 'nodeB',
c3: 'nodeC',
})
.addEdge('nodeA', 'finalize')
.addEdge('nodeB', 'finalize')
.addConditionalEdges('nodeC', afterC, {
nodeC: 'nodeC',
finalize: 'finalize',
})
.addEdge('finalize', END)
.compile();
}
type AiGraph = ReturnType<typeof buildGraph>;
/* ==========================================================================
* 4. Nest 服务:ConfigService 读 .env + 对外方法
* ========================================================================== */
// ⚠️ LangGraph 默认 recursionLimit=25;工具3 最多循环 20 次 + router + finalize 会超限,
// 所以每次调用都要显式抬高(见下方 RECURSION_LIMIT)
const RECURSION_LIMIT = 100;
@Injectable()
export class AiGraphService implements OnModuleInit {
private readonly logger = new Logger(AiGraphService.name);
private model!: ChatOpenAI;
private graph!: AiGraph;
constructor(private readonly config: ConfigService) {}
onModuleInit() {
const apiKey = this.config.getOrThrow<string>('OPENAI_API_KEY');
const modelName = this.config.get<string>('MODEL_NAME') ?? 'gpt-4o-mini';
const baseURL = this.config.get<string>('OPENAI_BASE_URL');
this.model = new ChatOpenAI({
modelName,
apiKey,
temperature: 0,
...(baseURL ? { configuration: { baseURL } } : {}),
});
this.graph = buildGraph(this.model);
this.logger.log(`AI graph ready (model=${modelName})`);
}
/** 一次性拿到最终结果 */
async run(input: string) {
const state = await this.graph.invoke(
{ input },
{ recursionLimit: RECURSION_LIMIT },
);
return {
result: state.result,
condition: state.condition,
loopCount: state.loopCount,
pages: state.items.length,
};
}
/** 流式:把每个节点的进展推给前端(工具3 循环时能看到翻页进度) */
async *stream(input: string) {
const stream = await this.graph.stream(
{ input },
{ streamMode: 'updates', recursionLimit: RECURSION_LIMIT },
);
for await (const chunk of stream) {
for (const [node, value] of Object.entries(chunk)) {
yield {
type: 'node',
node,
payload: (value as Record<string, unknown>) ?? {},
};
}
}
}
}
实现ai.module.ts和ai.controller.ts

测试



2.9 多agent案例
多agent主要用一个工具:@langchain/langgraph-supervisor
@langchain/langgraph-supervisor是什么?
一句话:它是多智能体的「包工头」------一个主管 Agent 调度多个专家 Agent,帮你把"谁该干哪件事"的路由逻辑和消息传递全包了。
底层原理不神秘------主管把每个专家当成一个"工具"来调用 ,交接机制(handoff)就是工具调用。所以主管输出的是「调 transfer_to_price_expert」,框架收到后把控制流转给对应子图,子图跑完把结果交回主管,主管再决定是继续派活还是结束。
实现muil-agent.graph.ts
定义了2个子agent是weatherAgent 和triviaAgent, 定义了一个调度员是:workflow,
createSupervisor是专门定义调度员的api。
export const app = workflow.compile();就是Agent实例,你可以调用agent的所有api,invoke、stream等
js
import 'dotenv/config';
import { HumanMessage } from '@langchain/core/messages';
import { createSupervisor } from '@langchain/langgraph-supervisor';
import { ChatOpenAI } from '@langchain/openai';
import { createAgent, tool } from 'langchain';
import { z } from 'zod';
import { lookupCityTrivia, lookupWeather } from './simple-mock.js';
const model = new ChatOpenAI({
modelName: process.env.MODEL_NAME,
apiKey: process.env.OPENAI_API_KEY,
configuration: {
baseURL: process.env.OPENAI_BASE_URL,
},
});
const lookupWeatherTool = tool(async ({ city }: any) => lookupWeather(city), {
name: 'lookup_weather',
description: '查询某城市当日天气概况(气温区间、天气、空气质量等)。',
schema: z.object({
city: z.string().describe('城市名,如 杭州'),
}),
});
const lookupCityTriviaTool = tool(
async ({ city }: any) => lookupCityTrivia(city),
{
name: 'lookup_city_trivia',
description: '查询与某城市相关的一句趣味知识。',
schema: z.object({
city: z.string().describe('城市名,如 杭州'),
}),
},
);
/** 子代理 A:只回答「天气」类问题 */
const weatherAgent = createAgent({
name: 'weather_agent',
description: '专门查天气',
model,
tools: [lookupWeatherTool],
systemPrompt:
'你只处理天气。用户提到城市时,用 lookup_weather 查询后再用中文简短说明。',
});
/** 子代理 B:只回答「城市小知识」 */
const triviaAgent = createAgent({
name: 'trivia_agent',
description: '专门讲与城市相关的小知识;必须调用 lookup_city_trivia。',
model,
tools: [lookupCityTriviaTool],
systemPrompt:
'你只讲城市小知识。先 lookup_city_trivia,再用人话转述,不要编造工具里没有的内容。',
});
/**
* Supervisor:根据用户问的是「天气」还是「小知识」切换子代理。
* (真实业务里还可以再加更多子代理,思路一样。)
*/
const workflow = createSupervisor({
agents: [weatherAgent.graph, triviaAgent.graph],
llm: model,
prompt: `你是调度员,只负责选人,不要自己报气温、也不要自己讲城市百科。
- 问天气、气温、下不下雨、空气 → 用 weather_agent
- 问小知识、名胜、历史、一句介绍 → 用 trivia_agent
`,
});
export const app = workflow.compile();
const drawable = await app.getGraphAsync();
console.log(drawable.drawMermaid({ withStyles: true }));
// 安全提取文本:content 可能是字符串,也可能是 content blocks 数组
const toText = (c: unknown): string => {
if (typeof c === 'string') return c;
if (Array.isArray(c)) {
return c
.map((b: any) => (typeof b === 'string' ? b : (b?.text ?? '')))
.join('');
}
return c == null ? '' : String(c);
};
export const getMultiAgent = async (text: string) => {
// ① 入口校验:绝不让 undefined 流进 LangChain
if (typeof text !== 'string' || !text.trim()) {
throw new Error('getMultiAgent: text 必须是非空字符串');
}
const input = { messages: [new HumanMessage(text)] };
const config = { recursionLimit: 50 };
const finalState: any = await app.invoke(input, config);
const last = finalState?.messages?.at(-1);
return { answer: toText(last?.content), raw: finalState?.messages ?? [] };
};
实现simple-mock.js
js
/** 假接口:演示 supervisor 如何把问题分给不同子代理 */
function normCity(city: string) {
return String(city).trim();
}
const weatherTable: Record<string, any> = {
杭州: { summary: '多云转小雨', tempHighC: 22, tempLowC: 15, aqi: '良' },
北京: { summary: '晴', tempHighC: 26, tempLowC: 12, aqi: '轻度污染' },
上海: { summary: '阴', tempHighC: 20, tempLowC: 16, aqi: '良' },
};
const triviaTable: Record<string, string> = {
杭州: '西湖文化景观是世界文化遗产之一。',
北京: '故宫是世界上现存规模最大的古代宫殿建筑群之一。',
上海: '外滩万国建筑博览群是近代城市历史的缩影。',
};
/** 查某地当日天气摘要(模拟) */
export function lookupWeather(city: string) {
const c = normCity(city);
const w = weatherTable[c];
if (!w) {
return JSON.stringify({
city: c,
summary: '暂无该城市数据,以下为占位',
tempHighC: 20,
tempLowC: 12,
aqi: '---',
});
}
return JSON.stringify({ city: c, ...w });
}
/** 查与某城市相关的一句小知识(模拟) */
export function lookupCityTrivia(city: string) {
const c = normCity(city);
const line = triviaTable[c];
return JSON.stringify({
city: c,
trivia: line ?? `没有为「${c}」准备内置小知识,可换杭州/北京/上海试试。`,
});
}
实现 ai.controller.ts
如果在graph里面你的agent实例使用的是stream,那些此时就应该定义一个SSE接口。我现在用的是invoke,那么就定义一个get接口

实现html

测试

mock里面没有兰州的数据,测试返回就是这样的

为什么呢?因为我们在prompt的时候把大模型的路堵死了。

那怎么才能打开这个路呢?通过prompt的描述就能随意打开还是关闭大模型的权限。
js
const triviaAgent = createAgent({
name: 'trivia_agent',
description: '讲城市小知识;优先查库,查不到用自己的知识并说明来源。',
model,
tools: [lookupCityTriviaTool],
systemPrompt: `你负责讲城市相关的小知识。执行规则:
1. 先用 lookup_city_trivia 查询该城市。
2. 如果工具返回 found: true → 基于返回内容转述,不要额外发挥。
3. 如果工具返回 found: false(库里没有这个城市)→ 改用你自己的知识回答,
并在句首标注「(来自通用知识)」,让用户知道这不是库里的权威数据。
4. 只有当你自己的知识里也确实没有该城市信息时,才说"暂未找到相关信息"。
不要说"暂无内置内容"这种暴露系统实现的话,用户不需要知道你背后有没有库。`,
});
总结
1.请问下面两行代码的区别的是什么?

解答:
首先在类里面,他们用起来都是用this.就好了。因为来源不同,所以初始化方式不同,如果是你自己的私有变量,就放到类里面,如果是nest的变量,就放到构造函数里面去。 

2.createAgent对langgraph封装的程度
createAgent封装了什么
js
graph.add_node("model", modelNode)
graph.add_node("tools", toolNode)
graph.add_edge(START, "model")
graph.add_conditional_edges("model", shouldContinue, {...}) // ← 条件边
graph.add_edge("tools", "model") // ← 回边,形成循环
return graph.compile()
他里面可以执行简单的条件语句和循环,但是不能全部都用createAgent, 那么什么时候用createAgent,什么时候langgraph?
| 你的情况 | 建议 |
|---|---|
| 工具选择该由模型决定("帮我看看订单怎么了") | ✅ 用 createAgent,一行搞定 |
| 工具选择由业务规则定死(条件1→工具1) | ❌ 手写图 |
循环终止有明确信号(hasMore、拉到空) |
❌ 手写图,别让模型瞎猜 |
| 循环终止靠语义判断("这些信息够了吗") | ✅ createAgent 或 decide 节点 |
| 需要自定义状态字段(page、items、审批状态) | ❌ 手写图 |
他们两个其实没有替换性,可以同时存在。