背景:
大家好,我是【小林】,近期对于AiAgent颇有兴趣,很多人都问,这么久不更新Flutter文章是不是弃坑了呢?
其实并没有,原因有以下,第一是近一个月工作比较忙,需求比较多,全身心的投入到了业务开发,没有太多时间去沉淀自己的想法和思路(仅剩不多的精力还留给了agent项目!!)第二就是流量不太好,我的Flutter文章虽然点赞、收藏、评论占阅读量的比重很大,但是阅读量始终卡在2000,让我也觉得比较缺乏动力...
“很多人觉得前端就是画画页面,调调接口。在 AI 时代,难道我们就只能做一个把 prompt 发给后端的‘搬运工’吗?去年在公司做文生图项目时,我发现传统的单次调用模式根本满足不了复杂需求。于是我决定利用下班精力,用 React + NestJS 自己打通这套 AI Agent 全栈工作流。这篇文章不仅是技术分享,更是一个前端开发的‘自救’笔记。”
话说回来,其实做这个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 工作流会:
- 分析目标读者(Planner)
- 搜索最新资讯(RAG)
- 生成大纲(Executor)
- 撰写正文(Executor)
- 检查质量(Critic)
场景2:智能客服 用户说"我的订单怎么还没到",传统方式可能直接查数据库。但 Agent 工作流会:
- 识别用户意图(查询订单/催单/退货?)
- 查询订单状态
- 判断是否异常
- 给出解决方案或转人工
场景3:AI 绘图助手(本文重点) 用户说"生成一只赛博朋克风格的猫",Agent 工作流会:
- 理解意图(文生图?局部重绘?)
- 检索相关风格(什么是赛博朋克?)
- 选择合适的模型和参数
- 执行生成
- 检查质量,不行就重试
[截图位置:展示一个完整的 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 项目真实截图
下面来看看我一个人配合AI,从零到一实现Agent项目的真实截图。请忽略AI生成的AI味UI【狗头保命】
- 整个项目初始化布局
- 对话管理
- 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;
}>;
}
状态设计原则:
- 不可变性:每个节点返回
Partial<AgentState>,而不是直接修改 - 可追溯:用
thoughtLogs记录每个节点的决策过程 - 可扩展:预留
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()
}]
};
}
}
技术要点:
- 结构化输出:用
jsonMode: true确保 LLM 返回 JSON - 温度参数:用
temperature: 0.3降低随机性,提高稳定性 - 业务逻辑纠错:蒙版存在时强制覆盖意图
3.2 RAG 节点:给 AI 一本"参考书"
职责:根据意图检索相关风格,增强 Prompt。
为什么需要 RAG?
用户的表达往往不专业。比如用户说"赛博朋克风格",但不知道专业的 Prompt 是什么。RAG 节点会:
- 在向量数据库中搜索"赛博朋克"
- 找到对应的 Prompt:"neon lights, high tech, low life"
- 拼接到用户的输入上
实现方式:
@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()
}]
};
}
}
技术要点:
- 向量数据库:用 LanceDB 做本地向量存储
- 相似度阈值:
minSimilarity: 0.4过滤低相关结果 - 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} />;
}
问题:
- 前后端耦合:新增组件类型,两边都得改
- Agent 不知道前端有什么组件
- 难以实现动态更新
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,它不只是显示图片,还能:
- 显示图片
- 绘制蒙版(红色半透明画笔)
- 撤销/重做
- 下载图片
技术实现:
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 开发的一点体会:
- 工作流设计是核心:别急着写代码,先画清楚节点和状态
- 状态管理要谨慎:LangGraph 的状态更新不是自动合并的
- 用户体验很重要:SSE 流式推送比"转圈等待"好太多
- 协议要提前设计:GenUI 协议如果后期重构,成本会很高
关于 LangGraph:
- ✅ 优势:灵活、可视化、支持复杂工作流
- ⚠️ 劣势:学习曲线陡,文档不够完善
- 💡 建议:先做简单 flow,再加复杂逻辑
如果你也在做 Agent,希望这篇文章能帮到你。有问题欢迎在评论区讨论,或者直接提 Issue。**
最后,如果这篇文章对你有帮助,点个赞吧~
(完)