“又一个新项目完结”——这句话说出口的时候我终于能把“全栈 AI 修图 Agent”从待办列表里划掉了。这个项目从立项到交付前后差不多一个多月期间推翻过一版架构也踩了不少模型和前后端的坑。如果你最近也在折腾 AI 全栈项目或者想看看 Agent 工程化到底怎么落地这篇博文应该能给你省下不少时间。一句话介绍这个项目它是一套基于自然语言的修图系统。用户上传一张图输入一句“帮我把背景换掉顺便调亮一点”系统里的 AI Agent 会自动理解意图、拆分任务、调度模型、执行修图最后返回成图。整个系统覆盖 Web 端和移动端前端用了 Vue3 和 Uniapp后端用的 Golang中间夹了一层 Agent 任务编排与模型网关。本文会把项目的设计思路、技术选型、核心实现和踩坑记录全部拆开来讲适合想独立做出一个 AI 全栈作品的开发者参考。1. 项目整体设计与需求拆解1.1 这个项目到底想解决什么问题传统修图软件的学习成本很高。想给一个非专业人士解释清楚“图层、蒙版、选区、曲线”实在太难了。哪怕是我自己遇到批量老照片修复或者一键换背景也不可能每张图都去 Photoshop 里手动操作。所以这个项目的核心定位非常明确用自然语言把修图意图说清楚剩下的交给 Agent 去拆解和执行。举一个最能说明问题的例子。用户输入“把照片的背景换成秋天的银杏叶”传统的做法是什么样的需要把人像抠出来、生成新的背景、再做人像与背景的融合、最后统一色调。这一套流程在 Photoshop 里至少涉及四五个环节。而在我们的系统里Agent 会把这句话拆成“语义分割人像、生成银杏背景、图像融合、色彩匹配”四个子任务再依次调用对应的模型工具最终合成一张成品图。用户不需要理解任何技术细节只需要表达“要什么效果”。这就是 Agent 和普通接口的本质区别。普通接口是“请求-处理-返回”的单次服务。Agent 则是一个带循环判断的系统接收用户需求、调度工具、检查结果、决定是否继续调整直到任务完成或者达到轮次上限。1.2 核心功能与能力边界在实际开发之前我先把功能边界画了一个表确定了第一版必须支持和可以延后的部分。这里直接把我们项目最初的功能清单分享出来功能模块具体能力优先级图片上传与存储支持 JPG/PNG/WebP统一压缩到 2048pxOSS 存储必须人像美化磨皮、美白、瘦脸等参数化操作必须背景处理抠图、背景替换、背景虚化必须老照片修复去噪、上色、超分必须色彩调整提亮、加滤镜、色温调整必须画面扩展以图生图方式扩展画面外区域延后批量处理一次提交多张图延后风格模板一键套用某种预设风格延后第一版不做批量处理是我反复权衡以后的决定。原因很简单Agent 带多轮工具调用的场景里单张图的执行链路已经很复杂如果再加上批量任务的并发编排问题排查难度会成倍增加。先跑通单张闭环再往横向扩展是这一类项目的稳妥节奏。1.3 Agent 修图与传统修图的架构差异很多刚接触 Agent 开发的人会陷入一个误区以为 Agent 就是在普通后端接口前面加一层大模型接口用户说什么直接转给大模型大模型返回什么就展示什么。但真正做过以后你会发现修图场景完全不是这么回事。一张图的修图结果没办法在文本对话里直接返回大模型能做的只是“决定该做什么”具体的图像处理必须由后端去调模型完成。所以我们的架构里Agent 扮演的不是“回答者”而是“调度中枢”。它接收用户需求输出结构化的工具调用指令后端拿到指令去执行真实的图像处理执行完再把结果摘要回传给 Agent让它判断任务是否结束或者下一步该做什么。这个设计直接决定了后续数据库要怎么设计、接口要怎么通信、前端状态要怎么管理。可以说所有在开发过程中让人头疼的问题最终都指向同一个根因这是一个异步、多步骤、带状态的 AI 工作流而不是一个同步的查询接口。2. 技术选型与整体架构设计2.1 前端为什么选 Vue3 和 Uniapp前端选择 Vue3首先是因为团队技术栈本来就是 Vue后面接手项目的人不至于看不懂。另外一个更实际的原因修图工作台的界面状态非常复杂画布区、参数面板、历史记录、指令输入框之间需要频繁跨组件通信。Vue3 的组合式 API 在处理这类复杂交互时代码组织形式比 Vue2 的 Options API 清晰得多。移动端选择 Uniapp考虑的是投入产出比。我们的目标不只是做 Web 端还希望用户能在手机上随手拍一张图直接处理。Uniapp 一套代码能同时输出 H5 和原生 App虽然性能上不如真正的原生开发但对于图片展示和表单交互这种场景已经完全够用。更关键的是uniapp 对 Vue 语法的兼容度很高Web 端的一些逻辑代码甚至可以直接复制到移动端使用省了非常多的重复工作量。这里要补充一个经验如果你只有一个前端同学想做多端Uniapp 是合理选择但如果是重交互的图片编辑器建议 Web 端还是单独用 Canvas 和 WebGL 做好基础再考虑迁移。我们第一版把大部分复杂操作放在 Web 端移动端只做上传、预览和结果展示就是为了避开移动端 Canvas 性能的坑。2.2 后端为什么选择 Golang后端选型我其实考虑过 Node.js、Python 和 Golang 三个方向。Node.js 的好处是前端同学能直接上手但我们的任务编排层涉及大量并发调度我希望有一个更强的并发模型。Python 在 AI 生态里确实无敌但部署要带运行环境和一堆依赖维护起来并不轻松。最后敲定 Golang理由有三个。第一Goroutine 的并发模型对 AI 任务调度非常友好。一个修图 Agent 任务会拆成多个子任务其中一些子任务之间是可以并行的比如同时做人像抠图和色彩分析。用 goroutine 做并行调度比起线程池和协程都更直观。第二Golang 编译出来就是单个二进制文件部署到服务器没有任何环境依赖问题。我们的 AI 模型部分虽然跑在独立的 GPU 机器上但服务端只需要通过 HTTP 调用模型接口所以 Golang 并不影响 AI 能力的接入。第三Gin 框架提供了非常简洁的路由和中间件机制SSE 流式输出的实现也足够稳定。我们整个后端只用了 Gin、GORM 和 SSE 相关的几个标准库没有引入额外重框架。2.3 AI 模型层本地模型与云端 API 混合路线模型层的设计是这个项目里最考验取舍的部分。最开始我考虑过全部接入开源模型自部署比如 Stable Diffusion 和 GFPGAN。但实际跑下来发现不同子任务的模型效果差异极大。有些开源小模型在老照片修复上效果根本不够看而商业 API 的表现则是开箱即用。最终我采用了混合路线主控 Agent意图识别、工具参数抽取使用商用大模型 API因为它的指令理解和 JSON 结构化输出能力稳定这是整个链条里最不能出错的部分。图像生成和编辑类任务使用开源 Stable Diffusion 系列模型部署在自己的 GPU 服务器上因为这类操作请求量大走本地模型可以控制成本。一些专项能力比如人像深度抠图、老照片修复用的是商业 API。这类任务对结果质量要求高开源小模型很难达到同一水平。这套混合架构的核心是一个模型网关层。后端服务不关心具体调的是本地服务还是外部 API只面向一个统一的接口封装。模型网关根据任务类型和当前服务器负载把请求路由到对应的模型实例。这给项目带来了两个直接好处一是某个模型挂了不会影响整个链路二是后续替换某个子任务的模型时不需要改动上层逻辑。2.4 整体架构分层整个系统从逻辑上可以分成五层展示层Vue3 Web 管理端、Uniapp 移动端接入层Golang 后端提供 REST 接口和 SSE 流式推送Agent 编排层负责意图理解、任务拆分、工具调用、结果验证工具执行层每个修图能力都封装成一个独立工具模型网关层统一调度本地模型和商业 API这里最关键的一点是 Agent 编排层和工作流运行时的边界要清楚。Agent 只输出“决策结果”而工作流运行时负责执行、状态管理和错误处理。如果没有这个边界所有逻辑都塞在 Agent 的对话循环里后期几乎没法维护。3. Agent 核心工作流的落地实现3.1 给 Agent 建立“能力说明书”一个修图 Agent 首先要让大模型知道自己有什么工具、能做什么事、不该做什么事。这部分我通过系统提示词来实现。Project 的 System Prompt 大致是如下结构你是一个修图任务调度管家负责将用户的修图需求拆解为可执行的工具调用序列。 你拥有以下工具 1. remove_background(image_id, output_format)移除图片背景 2. replace_background(image_id, background_prompt)替换图片背景 3. face_retouch(image_id, smooth_level, whitening_level)人像美化 4. restore_photo(image_id)老照片修复 5. adjust_color(image_id, brightness, contrast, saturation)色彩调整 ... 要求 1. 用户需求无法用现有工具实现时直接返回 error 说明原因 2. 一次请求可以调用多个工具工具之间按执行顺序排列 3. 结果满意后必须返回 done不要重复执行已完成的步骤这套提示词的核心是“给模型画清边界”。在测试过程中我明显感觉到提示词里如果没有明确说明“不要重复执行已完成的步骤”模型就很容易在第一步执行完后继续重复相同的工具调用白白浪费时间和算力。3.2 让大模型稳定输出工具调用参数Agent 和工具层之间的通信必须用结构化的方式不能直接让模型输出自然语言否则下游解析会非常痛苦。我们要求大模型始终返回一个 JSON 对象结构固定如下{ thought: 用户需要替换背景并调亮我先执行背景替换, tool_calls: [ { tool_name: replace_background, arguments: { image_id: img_10001, background_prompt: autumn gingko leaves } }, { tool_name: adjust_color, arguments: { image_id: img_10001, brightness: 1.2 } } ], is_finished: false }这里的is_finished是一个重要字段。模型完成所有工具调用后要把这个字段置为 true服务端看到 true 才会结束循环。为了提升 JSON 解析的成功率我把大模型接口的 temperature 参数刻意调低到 0.2 以内同时对返回内容做了一层清洗处理去掉 Markdown 代码块标记、修正未闭合的花括号。还有一个容易踩的坑是图像处理的中间结果管理。多个工具需要串行处理同一张图片时每次执行的输入可能是上一个工具的输出。因此我们不能让每个工具都从原始图片开始处理而是在运行时维护一个“当前图片”状态。比如上面对话的 JSON 里第二次调用的参数还是 image_id但实际在工具执行层该 id 对应的图片地址已经被第一次的工具调用结果覆盖了。3.3 工具注册与动态调度机制为了让 Agent 的“能力说明书”和实际的工具代码保持一致我实现了一套简单的工具注册机制。每个工具都是一个结构体type Tool interface { Name() string Description() string Parameters() map[string]interface{} Execute(ctx *ToolContext, args map[string]interface{}) (*ToolResult, error) }系统启动时所有工具会注册到一个工具表里。注册的同时会把工具的名字、描述、参数 Schema 自动拼接到 System Prompt 中。这样做的好处是以后新增一个修图能力只需要实现 Tool 接口然后在启动入口注册一下Agent 下次对话时就会自动感知到新工具的存在不需要手动修改提示词。动态调度的时候通过工具名从注册表找到对应的 Tool 实例参数则从 Agent 返回的 JSON 中抽取。有一个经验值得记录参数校验一定要做两层。第一层是 Agent 层的 Schema 校验第二层是工具内部的类型断言。因为大模型偶尔会返回brightness: 1.2这种字符串类型的数字如果工具内部不转类型程序就会直接 panic。3.4 多轮工具循环的状态管理一个修图需求往往需要好几轮工具调用。比如“把背景换掉顺便调亮”这个需求如果让 Agent 一次完成其实只需要一轮。但如果是“把背景换掉、调亮、再把人的皮肤磨一下”这种需求Agent 可能需要两到三轮才能把参数逐步确认好。整个循环的代码结构是经典的 while state update 模式for i : 0; i maxIterations; i { currentState : buildState(conversation, currentImageURL) llmResponse : callAgent(systemPrompt, currentState) if llmResponse.IsFinished { break } for _, call : range llmResponse.ToolCalls { tool : registry.Get(call.ToolName) result : tool.Execute(ctx, call.Arguments) appendToolResult(conversation, result) } }循环跳出条件有两个一是模型返回is_finished: true二是达到最大轮次上限。我给默认值设成了 5 轮超过 5 轮直接返回当前结果。这个上限非常重要没有它Agent 在某些奇怪输入下会一直循环调工具最终超时或烧光 token。3.5 结果是不是真的符合预期Agent 修图还有一个很容易被忽视的问题怎么判断结果“成功了”。之前我以为这一步很顺理成成实际开发时才发现图像处理工具到底有没有成功不能只看模型返回的 JSON 里success: true。有些模型返回成功但生成的结果图可能是黑图、糊图或者与原图毫无关系。所以我在工具执行层加了一个基础质量校验模块对所有模型输出的图片做三项检查宽度和高度是否大于预设阈值、图片文件字节数是否低于异常下限、清晰度评分是否超过基础线。任何一项不通过就会触发一次自动重试或者直接让 Agent 判定当前工具执行失败。这个设计确实救过我们一次。有一版我们在测试背景替换时某个场景的模型输出偶尔会出现纯色填充但接口本身是 200 状态。如果没有质量校验用户看到的就会是一张全黑图而且系统还认为任务完成了。4. 全栈实现与多端联调过程4.1 后端接口设计与 SSE 任务推送后端的核心接口大致分三组。第一组是会话管理接口负责创建会话和查询历史记录第二组是文件接口负责图片上传和临时 URL 生成第三组是任务接口负责提交 Agent 任务和接收进度推送。任务提交通道我最终选择了 SSEServer-Sent Events方案。原因很直接Agent 任务的处理时间通常需要 10 到 30 秒甚至更久用户需要实时看到“正在理解需求”“正在执行背景替换”“正在调整色调”这样的中间状态。如果只用轮询不仅浪费请求资源体验也差了很多。SSE 的推送格式设计成了事件流。前端通过 EventSource 连接后后端按阶段推事件event: stage data: {stage: understanding, message: 正在理解您的修图需求} event: stage data: {stage: tool_executing, tool: replace_background, message: 正在执行背景替换} event: stage data: {stage: image_ready, image_url: https://cdn.xxx.com/result.jpg}前端只需要监听不同的事件类型驱动页面上状态卡片和图片容器的更新。这里有个后端细节要注意SSE 的响应头必须设成Content-Type: text/event-stream并且关闭代理层的缓冲。我们第一次上线时Nginx 默认开了缓冲SSE 消息全部攒着不发前端一直停留在“理解中”排查了很久才找到原因。4.2 Web 端交互工作台的实现Web 工作台的界面不算复杂但状态管理很容易乱。我把页面拆成了四个核心模块左侧图片列表展示当前会话里所有上传图片和生成结果中间画布区展示当前编辑的图片支持缩放和比例标注右侧工具信息面板展示 Agent 执行的工具序列和每一步的执行结果底部指令输入框接收用户自然语言修图指令支持语音转文字状态管理使用 Pinia。整个会话上下文全部放在 store 里包括 session_id、image_list、execution_trace、is_processing 等字段。每次 SSE 事件到达时通过 store 的 action 统一更新状态。组件层只负责渲染不做数据逻辑。组件通信层级要注意一个细节Agent 的执行轨迹execution_trace是一个数组包含每一步的 tool_name、status、耗时、输出图片 URL。这个数组要始终在页面上可见这样用户才能理解 AI 为什么做了某些操作。如果只展示最终结果用户会对“AI 是不是偷偷改了别的地方”产生疑虑这类工具需要的是过程和结果都透明。4.3 移动端 Uniapp 的适配难点移动端用 Uniapp 实现功能上做了精简只保留了上传图片、发送指令、查看执行进度、查看结果、保存到相册这几个操作。核心业务逻辑从 Web 端抽取到公共的 JS 模块里两个端共享。实际开发中主要遇到两类差异。第一是文件上传方式。Web 端可以直接用 FormData 上传 Blob移动端则需要用 uni.uploadFile涉及临时文件路径的转换。第二是图片预览方式。Web 端可以渲染任意 CDN 地址的图片但移动端对某些 URL 有跨域限制需要统一走代理或加时间戳签名。我选用的做法是所有端前端在调用 Agent 之前都会对图片做一次前端预压缩统一将最长边压缩到 2048 像素内。这样既减小了上传体积也避免了同一个图片在不同端处理时尺寸不一致导致的 Agent 判断偏差。4.4 数据库表设计和状态流转数据库用的是 MySQL一共有五张核心表用户、会话、消息、图片、任务记录。这里重点说下任务表和消息表的设计。任务表记录了每次 Agent 执行的整体信息核心字段包含task_id、session_id、status、current_stage、error_message、completed_at。刚开始我并没有单独设计任务表而是把执行状态直接存在会话表里。但很快就发现一个问题一个会话可能包含多轮修图操作比如用户先让人像美颜再让改背景这两个是不同的任务如果状态都写在会话上就会出现互相覆盖。消息表的作用是保留 Agent 的完整对话记录也就是每条用户指令和 Agent 的中间决策过程。它有一个 tool_logs 字段类型是 JSON会记录每一轮工具调用时的输入参数和返回结果。开发 Agent 类项目时没有这些日志几乎没法调试你很难知道 Agent 当时为什么要做某个操作。状态流转上我定义了一套完整的状态机PENDING等待受理→ RUNNINGAgent 执行中→ SUCCEEDED完成 PENDING → RUNNING → FAILED失败任务超过 60 秒没有任何状态更新时服务端会强制把状态置成 FAILED 并向客户端推送超时事件。这是为了防止极端情况下的假死连接。4.5 联调过程中如何做端到端验证端到端验证这个环节我建议做成自动化脚本不要每次靠人工点页面测。我们写了一个小的 Go 脚本直接模拟客户端流程上传图片、创建会话、提交任务、接受 SSE 事件、校验最终图片 URL 是否返回 200。跑通之后再打开页面验证视觉交互。实际联调时最容易忽略的是图片在跨环境传输后的状态。同一张图在本地测试是正常的但上传到 OSS 之后可能因为访问权限、防盗链、CORS 配置等原因导致 Agent 后端无法正常拉取图片。所以我在服务端专门写了一个“图片预检”环节每次 Agent 收到图片 URL 后会先做一次 HEAD 请求确认可访问如果不可访问就直接失败并提示重新上传。5. 常见问题与排查技巧实录5.1 Agent 无限循环调用工具怎么办这个我前面提到过总轮次上限但实际项目中还有一种更隐蔽的情况虽然单轮不会卡死但模型会在相邻的两轮之间反复横跳。比如先调用了一次背景替换发现结果不满意又调用了一次恢复原图的工具然后再次调背景替换形成一个死循环。我在提示词和代码里做了双重防护。提示词里增加了“如果你发现自己即将重复执行已经做过的操作说明当前工具结果不满足预期应该直接返回 error 或 done”。代码里则增加了“工具调用指纹”去重如果 Agent 在最近 3 轮里对同一个图片、同一个工具、相似参数执行了 2 次系统会自动拦截并询问是否需要中止。5.2 SSE 连接总是挂断进度推送不稳定SSE 挂断的问题后来定位出来原因有三层。第一层是需要心跳机制。代理层如果没有数据流动空闲连接会被断开。所以我每 15 秒发一条event: heartbeat的注释消息保持连接活跃。第二层是代理层的缓冲。必须在中间件或 Nginx 明确关闭 response buffering否则数据到达不了浏览器。第三层是内存泄漏。有些版本的 Golang SSE 库在广播事件时如果不处理客户端断开的 channel会导致 goroutine 堆积。稳定以后我把整条 SSE 推送链路做了本地压力测试。模拟 50 个并发连接同时在线观察 10 分钟确认没有 goroutine 泄漏后才放心上线。5.3 大模型返回 JSON 偶尔解析失败解析失败是 Agent 开发中最常见的坑。大模型返回的内容里偶尔会混入额外的解释文字或者 JSON 外层包了 Markdown 代码块。最开始我直接对原字符串做 json.Unmarshal失败率很高后来做了三层解析容错。第一层是做字符串清洗去掉所有 Markdown 标记和第一个花括号之前的所有字符。第二层是尝试自动修复比如未闭合的数组和对象标记。第三层是解析失败时把错误信息回传给大模型让它自己修正输出。这一步效果非常明显连续重试两次后解析失败率几乎降到了零。有一个值得注意的细节给大模型回传错误信息时要明确告诉它“上一次的返回中包含了多余的文本请只返回 JSON不要其他内容”否则它很容易继续沿袭上一次的错误格式。5.4 图片方向、格式和 EXIF 问题的坑手机上传的图片普遍存在 EXIF 反转问题。就是说用户拍照时手机是横着的但图片本身经过旋转处理后才正常显示。如果后端拿到图片后直接做处理出来的结果可能是横的或者颠倒的。这个问题在 Web 端偶尔出现在移动端几乎百分百触发。后来我在所有图像工具的第一道处理里加了一个“图像标准化”流程读取 EXIF 信息、根据 Orientation 字段做旋转矫正、统一转成 RGB 三通道、删除透明通道、将最长边缩放到 2048px。做完标准化之后所有下游模型拿到的都是规格一致的图片模型因为输入尺寸不一致引发的随机问题少了很多。5.5 不同模型对输入和输出的要求不一致本地 Stable Diffusion 模型和商业 API 的输入限制差异很大。有的模型要求图片最小边长不低于 512px有的模型又限制最大解析度。图像标准化流程解决了大部分问题但还是有极少数图片处理完以后生成的图片分辨率过低。我们在模型网关层为每个模型配置了“输入约束”和“输出约束”。网关在调用模型前会根据约束做一次检查比如判断当前图片的宽度是否低于模型最低要求低了就用智能超分先放大确保模型输入符合预期。5.6 移动端真机测试时才发现的内存暴涨问题这个坑是在移动端出现的。Uniapp 在真机上连续处理几张 2048px 的大图时内存占用一路飙升最后直接白屏。排查发现是在图片预览组件里没有释放历史图片资源。后来我们把移动端的图片预览全部改为懒加载模式并且每次切换图片时手动回收不再可见的图片对象。真机调试这个事情经验就是一定要准备一台中低端 Android 手机做测试很多性能问题在高端机上根本复现不出来。最后的一点体会这个项目最大的收获不是模型调得多好而是深刻理解了 Agent 工程化到底是做什么模型负责理解和生成但工程负责边界、容错和确定性。用户能不能稳定地用上这个功能取决于你给 Agent 画了多清晰的边界、做了多完善的兜底而不是模型本身有多聪明。最后分享一个个人偏好开发这种带状态和循环的系统一定要把所有 Agent 决策日志落库。我们最初只把最终结果存下来了结果模型一跑偏很难判断是哪个环节出了问题只能靠猜。后来把每一轮的原始输入输出、工具调用参数、异常信息全部记录下来调试效率直接翻倍。以后再做 Agent 类项目我会把日志体系当成基础设施一样在项目第一天就搭好。这个项目做完以后我还在想能不能把任务编排的逻辑抽出来做一个通用的 Agent 工作流脚手架到时候再跟大家分享。
