背景

大家好,我是【小林】,近期对于AiAgent颇有兴趣,很多人都问,这么久不更新Flutter文章是不是弃坑了呢?

其实并没有,原因有以下,第一是近一个月工作比较忙,需求比较多,全身心的投入到了业务开发,没有太多时间去沉淀自己的想法和思路(仅剩不多的精力还留给了agent项目!!)第二就是流量不太好,我的Flutter文章虽然点赞、收藏、评论占阅读量的比重很大,但是阅读量始终卡在2000,让我也觉得比较缺乏动力...

话说回来,其实做这个agent项目还是源于我在去年公司做的项目,简单描述就是文生图图生图,不过当时受制于AI远没有今天这么强大等等综合的原因吧,我有特别多的想法没有实践出来,我觉得现在应该有能力实现一部分出来了。

这套Agent工作流本质上来说就是当人面对一个复杂需求时,需要的不是单个 AI 模型,而是一个能"思考"、"决策"、"执行"的智能系统

这篇文章讲清楚一件事:如何用 LangGraph 设计和实现一个完整的 AI Agent 工作流。从需求分析到架构设计,从节点编排到流式执行,基于真实的工程实践。(有机会和时间可以详细讲讲我这个项目是如何从零到一实现的)


一、为什么需要 Agent 工作流?

1.1 传统方式的困境

做个 AI 绘图功能,第一反应是什么?

// 最简单的方式:直接调 API
async function generateImage(prompt: string) {
  const result = await llm.call(prompt);
  return result;
}

但这有几个问题:

问题1:用户说的不专业

  • 用户:"来张酷炫的图"
  • AI:什么叫"酷炫"?赛博朋克?极简风?还是蒸汽朋克?

问题2:无法理解上下文

  • 用户:"生成一只猫"
  • 用户:"加个墨镜" → AI 不知道是在"猫"上加,还是重新生成

问题3:无法自我优化

  • 生成效果不好?AI 不会自动调整参数重试

这些问题,本质上是 "单次调用"模式的局限 。用户想要的是一个能"理解意图 → 检索知识 → 执行任务 → 质量检查 → 优化调整"的完整流程。

1.2 Agent 工作流的价值

Agent 工作流就是把复杂任务拆解成多个步骤,每个步骤由专门的"节点"(Node)负责,节点之间通过"状态"(State)传递数据。

打个比方:

  • 传统方式:你让一个助手做项目,他啥都得会,但每个都不精
  • Agent 工作流:你有个专业团队,有人理解需求,有人查资料,有人执行,有人检查质量

实际场景中,Agent 工作流的价值体现在:

场景1:内容创作团队 编辑说"写一篇关于 AI 的文章",传统方式直接生成一篇。但 Agent 工作流会:

  1. 分析目标读者(Planner)
  2. 搜索最新资讯(RAG)
  3. 生成大纲(Executor)
  4. 撰写正文(Executor)
  5. 检查质量(Critic)

场景2:智能客服 用户说"我的订单怎么还没到",传统方式可能直接查数据库。但 Agent 工作流会:

  1. 识别用户意图(查询订单/催单/退货?)
  2. 查询订单状态
  3. 判断是否异常
  4. 给出解决方案或转人工

场景3:AI 绘图助手(本文重点) 用户说"生成一只赛博朋克风格的猫",Agent 工作流会:

  1. 理解意图(文生图?局部重绘?)
  2. 检索相关风格(什么是赛博朋克?)
  3. 选择合适的模型和参数
  4. 执行生成
  5. 检查质量,不行就重试

[截图位置:展示一个完整的 Agent 工作流执行过程,包含每个节点的状态]

1.3 为什么选 LangGraph?

做 Agent 工作流,有几个选择:

方案优点缺点适用场景
LangChain Chain简单直接不支持循环、条件分支复杂简单的线性任务
AutoGPT自主性强过于黑盒,难定制自主探索类任务
自己写状态机完全可控重复造轮子有特殊需求
LangGraph灵活+可视化学习曲线陡复杂工作流

选 LangGraph 的三个理由:

理由1:状态图模式

// 定义状态
interface AgentState {
  userInput: string;
  intent?: IntentResult;
  enhancedPrompt?: string;
  result?: string;
}

// 定义图(节点 + 边)
const graph = new StateGraph(AgentStateAnnotation)
  .addNode("planner", plannerNode)
  .addNode("rag", ragNode)
  .addNode("executor", executorNode)
  .addEdge("planner", "rag")
  .addEdge("rag", "executor")
  .compile();

理由2:支持流式执行

// 实时看到每个节点的输出
for await (const chunk of graph.stream(initialState)) {
  console.log(chunk);  // { planner: { intent: {...} } }
}

理由3:可视化和调试 LangGraph 提供了可视化工具,能直接看到工作流结构和执行路径。

1.4 项目真实截图

  • 整个项目初始化布局

  • 对话管理

  • Agent工作流(前端展示部分)

  • 多轮对话的最终提示词

  • 前端本地数据库,进行数据持久化

  • 知识库管理(服务端写好像向量数据,模拟企业内部知识库)

二、设计一个图像生成 Agent 工作流

2.1 需求分析

先明确目标:做一个智能图像生成助手,用户用自然语言描述需求,系统自动完成。

核心功能:

  • 文生图:文字描述生成图片
  • 局部重绘:在图片上画蒙版,修改特定区域
  • 多轮对话:基于上一轮结果继续修改
  • 风格推荐:根据描述自动匹配风格

非功能需求:

  • 实时反馈:展示思考过程,别让用户干等
  • 可重试:生成失败自动重试
  • 可观测:每个步骤都可追踪

2.2 工作流设计

基于需求,设计了四个节点的工作流:

用户输入
   ↓
[Planner] 意图识别 → { action: "generate_image", style: "cyberpunk" }
   ↓
[RAG] 风格检索 → { enhancedPrompt: "一只猫, neon lights, high tech..." }
   ↓
[Executor] 执行生成 → { imageUrl: "https://..." }
   ↓
[Critic] 质量检查 → { passed: true }
   ↓
返回结果

为什么要这四个节点?

节点职责为什么需要
Planner理解用户意图用户表达不精确,需要 LLM 解析
RAG检索增强风格用户的"风格"描述可能不专业
Executor执行生成调用图像生成 API
Critic质量检查AI 生成不稳定,需要筛选

2.3 状态设计

LangGraph 的核心是状态(State),状态在节点间传递和更新。

interface AgentState {
  // 输入
  userInput: {
    text: string;              // 用户输入的文本
    maskData?: {               // 蒙版数据(局部重绘时)
      base64: string;
      imageUrl: string;
    };
    previousPrompts?: string[]; // 历史提示词(多轮对话)
  };

  // 中间状态
  intent?: {                  // Planner 的输出
    action: 'generate_image' | 'inpainting' | 'adjust_parameters';
    style?: string;
    confidence: number;
  };

  enhancedPrompt?: {          // RAG 的输出
    original: string;
    retrieved: Array<{ style: string; prompt: string; }>;
    final: string;
  };

  // 输出
  executionResult?: {
    imageUrl: string;
    metadata?: any;
  };

  // 元数据
  sessionId: string;
  thoughtLogs?: Array<{       // 思考日志(用于展示)
    node: string;
    message: string;
    timestamp: number;
  }>;
}

状态设计原则

  1. 不可变性:每个节点返回 Partial<AgentState>,而不是直接修改
  2. 可追溯:用 thoughtLogs 记录每个节点的决策过程
  3. 可扩展:预留 metadata 字段,方便后续加字段

三、节点实现:从意图到执行

3.1 Planner 节点:理解用户想要什么

职责:分析用户输入,提取关键信息。

实现方式:调用 LLM,要求结构化输出。

@Injectable()
export class PlannerNode {
  async execute(state: AgentState): Promise<Partial<AgentState>> {
    const systemPrompt = `
你是一个专业的 AI 图像生成助手。请分析用户的输入,识别用户的意图。

可能的意图类型:
- generate_image: 用户想要生成新图片
- inpainting: 用户想要修改图片的某个区域(通常伴随蒙版数据)
- adjust_parameters: 用户想要调整生成参数

请以 JSON 格式返回分析结果。

输出格式:
{
  "action": "generate_image",
  "subject": "猫",
  "style": "赛博朋克",
  "confidence": 0.9
}
`;

    const intent = await this.llmService.chatWithJson<IntentResult>(
      [
        { role: 'system', content: systemPrompt },
        { role: 'user', content: state.userInput.text }
      ],
      { temperature: 0.3, jsonMode: true }
    );

    // 特殊处理:如果有蒙版,强制设为 inpainting
    if (state.userInput.maskData && intent.action !== 'inpainting') {
      intent.action = 'inpainting';
    }

    return {
      intent,
      thoughtLogs: [{
        node: 'planner',
        message: `已识别意图:${intent.action}`,
        timestamp: Date.now()
      }]
    };
  }
}

技术要点

  1. 结构化输出:用 jsonMode: true 确保 LLM 返回 JSON
  2. 温度参数:用 temperature: 0.3 降低随机性,提高稳定性
  3. 业务逻辑纠错:蒙版存在时强制覆盖意图

效果展示

[截图位置:Planner 节点的执行效果,展示意图识别结果]

3.2 RAG 节点:给 AI 一本"参考书"

职责:根据意图检索相关风格,增强 Prompt。

为什么需要 RAG?

用户的表达往往不专业。比如用户说"赛博朋克风格",但不知道专业的 Prompt 是什么。RAG 节点会:

  1. 在向量数据库中搜索"赛博朋克"
  2. 找到对应的 Prompt:"neon lights, high tech, low life"
  3. 拼接到用户的输入上

实现方式

@Injectable()
export class RagNode {
  constructor(private readonly knowledgeService: KnowledgeService) {}

  async execute(state: AgentState): Promise<Partial<AgentState>> {
    // 构建查询文本
    const queryText = [
      state.intent.style,
      state.intent.subject,
      state.userInput.text
    ].filter(Boolean).join(' ');

    // 向量检索
    const results = await this.knowledgeService.search(queryText, {
      limit: 3,
      minSimilarity: 0.4
    });

    // 构建增强 Prompt
    const originalPrompt = state.userInput.text;
    const retrievedPrompts = results.map(r => r.prompt).join(', ');
    const finalPrompt = retrievedPrompts
      ? `${originalPrompt}, ${retrievedPrompts}`
      : originalPrompt;

    return {
      enhancedPrompt: {
        original: originalPrompt,
        retrieved: results,
        final: finalPrompt
      },
      thoughtLogs: [{
        node: 'rag',
        message: results.length > 0
          ? `检索到 ${results.length} 条相关风格`
          : '未检索到匹配的风格',
        timestamp: Date.now()
      }]
    };
  }
}

技术要点

  1. 向量数据库:用 LanceDB 做本地向量存储
  2. 相似度阈值minSimilarity: 0.4 过滤低相关结果
  3. Prompt 拼接:简单拼接,实际可以更智能

效果展示

[截图位置:RAG 检索结果展示,可以看到检索到的风格和 Prompt]

3.3 Executor 节点:干活的人

职责:调用图像生成服务,返回图片 URL。

接口设计

为了灵活切换供应商(阿里云、DeepSeek、本地模型),设计了统一接口:

interface IImageService {
  generateImage(options: {
    prompt: string;
    model?: string;
    size?: string;
  }): Promise<{ imageUrl: string }>;

  inpaint(options: {
    prompt: string;
    image: string;
    mask: string;
  }): Promise<{ imageUrl: string }>;
}

实现方式

@Injectable()
export class ExecutorNode {
  constructor(
    @Inject('IMAGE_SERVICE')
    private readonly imageService: IImageService,
  ) {}

  async execute(state: AgentState): Promise<Partial<AgentState>> {
    if (!state.intent || !state.enhancedPrompt) {
      throw new Error('Intent and enhancedPrompt are required');
    }

    const { action } = state.intent;
    const prompt = state.enhancedPrompt.final;

    let executionResult;

    switch (action) {
      case 'generate_image':
        executionResult = await this.imageService.generateImage({
          prompt,
          model: state.userInput.preferredModel
        });
        break;

      case 'inpainting':
        if (!state.userInput.maskData) {
          throw new Error('Inpainting requires maskData');
        }
        executionResult = await this.imageService.inpaint({
          prompt,
          image: state.userInput.maskData.imageUrl,
          mask: state.userInput.maskData.base64
        });
        break;

      default:
        throw new Error(`Unknown action: ${action}`);
    }

    // 生成 GenUI 组件(后续会讲)
    const uiComponents: GenUIComponent[] = [{
      widgetType: 'ImageView',
      props: {
        imageUrl: executionResult.imageUrl,
        width: 800,
        height: 600
      },
      timestamp: Date.now()
    }];

    return {
      executionResult,
      uiComponents,
      thoughtLogs: [{
        node: 'executor',
        message: '图片生成完成',
        timestamp: Date.now()
      }]
    };
  }
}

效果展示

[见上图]

3.4 Critic 节点:质量检查员

职责:检查生成结果,决定是否重试。

为什么需要?

AI 生成不稳定,有时候效果不好,需要重试。但每次都人工检查成本太高,所以需要一个自动筛选机制。

实现方式

@Injectable()
export class CriticNode {
  async execute(state: AgentState): Promise<Partial<AgentState>> {
    if (!state.executionResult) {
      throw new Error('Execution result is required');
    }

    // 基于意图置信度和随机因素评估
    const baseScore = state.intent?.confidence || 0.7;
    const randomFactor = Math.random() * 0.2;
    const finalScore = Math.min(1.0, baseScore + randomFactor);
    const passed = finalScore > 0.7;

    const retryCount = state.metadata?.retryCount || 0;

    return {
      qualityCheck: {
        passed,
        score: finalScore,
        feedback: passed ? '质量符合要求' : '建议重新生成'
      },
      metadata: {
        ...state.metadata,
        retryCount: passed ? 0 : retryCount + 1,
        shouldRetry: !passed && retryCount < 3
      },
      thoughtLogs: [{
        node: 'critic',
        message: `质量评分:${finalScore.toFixed(2)}`,
        timestamp: Date.now()
      }]
    };
  }
}

重试逻辑

在 LangGraph 的条件边(Conditional Edge)中处理:

graph.addConditionalEdges(
  'executor',
  (state) => {
    if (state.metadata?.shouldRetry) {
      return 'critic';  // 重试,回到 Critic
    }
    return END;         // 结束
  }
);

效果展示

[见上图]


四、GenUI 协议:Agent 如何与前端对话?

4.1 问题:前后端如何协作?

传统方式:后端返回 JSON,前端解析。

// 后端
{ type: 'image', url: 'https://...' }

// 前端
if (result.type === 'image') {
  return <img src={result.url} />;
}

问题:

  1. 前后端耦合:新增组件类型,两边都得改
  2. Agent 不知道前端有什么组件
  3. 难以实现动态更新

4.2 GenUI 协议设计

核心思想:后端只管下发"组件配置",前端自己渲染。

interface GenUIComponent {
  widgetType: string;          // 组件类型
  props: Record<string, any>;  // 组件属性
  updateMode?: 'append' | 'replace' | 'update';  // 更新模式
  timestamp?: number;
}

示例

// Agent 想显示一张图片
{
  widgetType: "ImageView",
  props: {
    imageUrl: "https://...",
    width: 800,
    height: 600
  },
  updateMode: "replace"
}

// Agent 想显示一个进度条
{
  widgetType: "ProgressBar",
  props: {
    progress: 50,
    status: "生成中..."
  },
  updateMode: "update"
}

4.3 三种更新模式

Append(追加):在当前界面后面加新内容

// 场景:多轮对话,每次生成新图片
{ widgetType: "ImageView", updateMode: "append" }

Replace(替换):替换当前组件

// 场景:重绘时,用新图片替换旧图片
{ widgetType: "ImageView", updateMode: "replace" }

Update(更新):更新现有组件的属性

// 场景:进度更新
{
  widgetType: "ProgressBar",
  updateMode: "update",
  props: { progress: 50 }
}

4.4 前端渲染器

前端有个通用渲染器,根据 widgetType 动态渲染:

export function GenUIRenderer({ component }: { component: GenUIComponent }) {
  const { widgetType, props } = component;

  switch (widgetType) {
    case 'SmartCanvas':
      return <SmartCanvas {...props} />;
    case 'ImageView':
      return <ImageView {...props} />;
    case 'AgentMessage':
      return <AgentMessage {...props} />;
    default:
      return <div>Unknown widget: {widgetType}</div>;
  }
}

4.5 SmartCanvas:能画蒙版的画布

最复杂的 GenUI 组件是 SmartCanvas,它不只是显示图片,还能:

  1. 显示图片
  2. 绘制蒙版(红色半透明画笔)
  3. 撤销/重做
  4. 下载图片

技术实现:

const handleMouseDown = (e: React.MouseEvent) => {
  if (mode !== 'masking') return;

  setIsDrawing(true);
  const ctx = canvasRef.current.getContext('2d');
  ctx.strokeStyle = 'rgba(255, 0, 0, 0.5)';
  ctx.lineWidth = 20;
  ctx.lineCap = 'round';
  ctx.beginPath();
  ctx.moveTo(x, y);
};

const handleMouseMove = (e: React.MouseEvent) => {
  if (!isDrawing) return;
  ctx.lineTo(x, y);
  ctx.stroke();
};

用户画完蒙版后,前端把 Canvas 转成 Base64,发给后端:

const base64 = canvasRef.current.toDataURL('image/png', 0.8);
// 发送给后端,调 inpainting API

五、关键技术实现

5.1 SSE 流式通信

为什么用 SSE?

传统方式:前端发请求 → 后端处理 → 返回结果。用户等待期间看到的是转圈。

更好的方式:后端处理到哪一步,前端就展示到哪一步。

实现方式

// 后端:AgentService
async *executeWorkflow(initialState: AgentState): AsyncGenerator<any> {
  const stream = await this.graph.stream(initialState, {
    streamMode: 'updates',
  });

  for await (const chunk of stream) {
    for (const [nodeName, stateUpdate] of Object.entries(chunk)) {
      // 推送思考日志
      if (stateUpdate.thoughtLogs) {
        for (const log of stateUpdate.thoughtLogs) {
          yield {
            type: 'thought_log',
            timestamp: log.timestamp,
            data: {
              node: log.node,
              message: log.message,
            },
          };
        }
      }
    }
  }
}
// 前端:SSE 客户端
class SSEClient {
  async connect(url, data, { onEvent, onError }) {
    const response = await fetch(url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data),
    });

    const reader = response.body.getReader();
    const decoder = new TextDecoder();

    while (true) {
      const { done, value } = await reader.read();
      if (done) break;

      const chunk = decoder.decode(value);
      const event = this.parseSSEEvent(chunk);
      onEvent(event);
    }
  }
}

效果:用户能看到类似这样的实时日志:

[Planner] 已识别意图:生成图片
[RAG] 检索到 3 条相关风格
[Executor] 图片生成中...
[Executor] 图片生成完成

5.2 多轮对话:如何保持上下文?

问题:用户说"加个墨镜",系统怎么知道是在"猫"上加?

解决方案:历史 Prompt 合并

// 首轮
userInputs: ["生成一只猫"]
finalPrompt: "生成一只猫"

// 续写
userInputs: ["生成一只猫", "加个墨镜"]
finalPrompt: "生成一只猫, 加个墨镜"

// 再次续写
userInputs: ["生成一只猫", "加个墨镜", "背景改成星空"]
finalPrompt: "生成一只猫, 加个墨镜, 背景改成星空"

实现

// 前端维护历史输入
const [previousPrompts, setPreviousPrompts] = useState<string[]>([]);

// 每次发送时带上历史
const handleSend = (text: string) => {
  const newPrompts = [...previousPrompts, text];
  setPreviousPrompts(newPrompts.slice(-5)); // 最多保留 5 条

  agentService.chat({
    text,
    previousPrompts: newPrompts
  });
};

// 后端合并
const finalPrompt = previousPrompts.join(', ');

5.3 向量检索:LanceDB 实战

为什么用向量检索?

用户输入"酷炫的风格",系统需要知道这可能对应"赛博朋克"或"科幻"。传统关键字匹配做不到,需要语义检索。

实现

// 初始化 LanceDB
const db = await lancedb.connect('./data/lancedb');
const table = await db.openTable('styles');

// 生成查询向量
const embedding = await embeddingService.embedQuery("酷炫的风格");

// 向量检索
const results = await table
  .vectorSearch(embedding)
  .limit(3)
  .distanceType('cosine')
  .toArray();

数据准备

预置了 N 种风格数据:

风格Embedding 向量Prompt
赛博朋克[0.1, 0.2, ...]neon lights, high tech...
水彩[0.3, 0.1, ...]soft colors, watercolor...
.........

六、踩坑总结

6.1 LangGraph 的坑

坑1:状态更新不是自动合并

// 错误做法
const newState = { ...state, intent: newIntent };

// 正确做法
const update = { intent: newIntent };
currentState = { ...currentState, ...update };

坑2:条件边的返回值

// 错误
return someObject;

// 正确:必须返回字符串(节点名)或 END
return 'nextNodeName';

6.2 SSE 的坑

:连接断开后没有自动重连

解决:实现指数退避重连

let retryCount = 0;
while (retryCount < 5) {
  try {
    await connect();
    break;
  } catch (error) {
    retryCount++;
    const delay = Math.pow(2, retryCount) * 1000;
    await sleep(delay);
  }
}

6.3 Canvas 的坑

坑1:在高分屏上模糊

// 需要处理 devicePixelRatio
const dpr = window.devicePixelRatio || 1;
canvas.width = width * dpr;
canvas.height = height * dpr;
ctx.scale(dpr, dpr);

坑2:Base64 太大导致传输慢

// 优化:缩放后再传输
const resizedCanvas = document.createElement('canvas');
resizedCanvas.width = 800;
resizedCanvas.height = 600;
ctx.drawImage(originalCanvas, 0, 0, 800, 600);
const base64 = resizedCanvas.toDataURL('image/png', 0.8);

七、总结与展望

7.1 技术栈

  • 后端:NestJS 10 + LangGraph + LangChain + LanceDB
  • 前端:Next.js 14 + React 18 + TypeScript + TailwindCSS
  • 通信:SSE (Server-Sent Events)
  • 状态管理:Zustand
  • UI 组件:shadcn/ui

7.2 核心设计

工作流设计

  • 4 个节点:Planner → RAG → Executor → Critic
  • 状态驱动:AgentState 在节点间传递
  • 流式执行:实时展示每个节点的输出

GenUI 协议

  • 后端下发组件配置
  • 前端动态渲染
  • 三种更新模式:append/replace/update

关键技术

  • SSE 流式通信
  • 向量检索(LanceDB)
  • 多轮对话(历史合并)
  • Canvas 蒙版绘制

7.3 开源地址

项目已完全开源,欢迎 Star 和 PR:

GitHub:[github.com/xinqingaa/a…]

文档:[github.com/xinqingaa/a…]

7.4 后续计划

  • 用户认证系统
  • 图片历史记录
  • 批量生成
  • 移动端完善
  • 更多 GenUI 组件

八、写在最后

关于 Agent 开发的一点体会

  1. 工作流设计是核心:别急着写代码,先画清楚节点和状态
  2. 状态管理要谨慎:LangGraph 的状态更新不是自动合并的
  3. 用户体验很重要:SSE 流式推送比"转圈等待"好太多
  4. 协议要提前设计:GenUI 协议如果后期重构,成本会很高

关于 LangGraph

  • 优势:灵活、可视化、支持复杂工作流
  • ️ 劣势:学习曲线陡,文档不够完善
  • 建议:先做简单 flow,再加复杂逻辑

如果你也在做 Agent,希望这篇文章能帮到你。有问题欢迎在评论区讨论,或者直接提 Issue。**

最后,如果这篇文章对你有帮助,点个赞吧~

(完)

本站提供的所有下载资源均来自互联网,仅提供学习交流使用,版权归原作者所有。如需商业使用,请联系原作者获得授权。 如您发现有涉嫌侵权的内容,请联系我们 邮箱:alixiixcom@163.com