ai agent --- 多agent框架之图编排引擎-langgraph

一. 多 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?

  1. 上下文真的装不下了 ------ 工具超过 8-10 个,或单次任务信息量超过 100K token
  2. 任务可以真正并行 ------ 多个独立子任务,串行做太慢
  3. 需要不同的人格/权限 ------ 比如一个 Agent 只读、一个可写;一个面向用户、一个做内部分析

除此之外,优先优化单 Agentprompt 和工具描述,性价比高得多。

1.4技术栈的支持程度

你前面在看 AG-UIVercel,这两家对多 Agent 的支持程度不一样:

  • AG-UI :有 parentRunId 字段,协议层就支持子 Agent 事件挂在父 run 下,天然适合多 Agent 树。但多 Agent 编排本身靠 RAWCUSTOM 事件扩展,协议没规定怎么编排。
  • Vercel AI SDK :有 start-step / finish-step 做步骤边界,但没有原生的 Agent 树概念,扁平的。

真要落地多 Agent,编排逻辑(谁派给谁、怎么汇总)得靠 LangGraph、或自己写状态机------这两个协议都只管传输,不管编排。

1.4 多agent的好处

1.5 agent对比

线性编排 agent langchain 实现,典型结构如:LCEL

agentlanggraph实现,LangGraphLangChain团队开发的开源图编排框架

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 });

servicecontroller里面添上接口

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 确认之后继续执行

就是说整个流程有个中断的时候。比如确认转账的场景。

转账中,state2个,一个是用户输入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个子agentweatherAgent 和triviaAgent, 定义了一个调度员是:workflow

createSupervisor是专门定义调度员的api

export const app = workflow.compile();就是Agent实例,你可以调用agent的所有apiinvoke、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、审批状态) ❌ 手写图

他们两个其实没有替换性,可以同时存在。

相关推荐
竹林8181 小时前
OmniPic Studio v3.2.1 核心技术架构与全平台发版解析文档
前端·浏览器
JamesZhang800781 小时前
页面内存只涨不跌? 一次泄漏排查, 牵出 WeakMap 的诞生
前端
Z小明1 小时前
第 6 章 组件进阶
前端·vue.js
江华森1 小时前
HTTP请求的完整过程详解:从DNS解析到TCP挥手的微秒级实战分析
前端
南青1 小时前
Vue 3 中后台实战:我踩过的 10 个坑和最佳实践
前端
江华森1 小时前
HTTPS协议详解——SSL/TLS握手、证书与加密通信
前端
江华森1 小时前
从一个HTTP请求看网络分层原理:基于华为云ECS的真实抓包实战
前端
江华森1 小时前
TCP协议详解——三次握手、四次挥手与连接状态
前端
甲维斯1 小时前
《钢铁洪流》官网搞定,纯AI制作,Opus5操刀!
前端·人工智能·游戏开发