最近刚把一个全栈 AI 修图 Agent 的项目收尾上线,整个过程踩了不少坑,也沉淀了不少经验。这个项目从立项到完结大概花了两个多月,覆盖了 Vue3 + Golang + UniApp 三端,核心是把 AI 图像处理和 Agent 的任务编排结合起来,做成了一个用户用自然语言就能完成修图的智能工具。如果你正在搞全栈、想接触 Agent 开发,或者对 AI 图像处理落地感兴趣,那这篇文章应该能给你一些真实可参考的东西——包括技术选型为什么这么做、Agent 循环怎么设计、多端适配有哪些坑,还有我最后悔没有早点知道的几个优化点。
项目标题虽然是"又一个新项目完结",但对我来说,它不只是多了一个作品集项目,更像是一次把大模型能力、Agent 编排、图像处理和传统全栈开发揉在一起的完整实践。
1. 项目整体设计与技术选型的底层逻辑
1.1 为什么做全栈 AI 修图 Agent,而不是普通的修图工具
先说说这个项目想解决的问题。市面上大多数修图工具,不管是网页版还是客户端,核心交互都是"用户手动操作按钮和参数"——调亮度、拉对比度、抠图、磨皮,每一个动作都得用户自己去点。这个方案本身没问题,但有一个天然的门槛:很多人根本不知道某个效果应该用哪个功能实现。比如"把背景里的路人去掉"这件事,在传统工具里可能涉及选区、内容识别填充、补图好几个步骤,小白根本无从下手。
AI 修图 Agent 的思路就不一样了。用户只需要用一句话描述目标,比如"把这张照片的背景换成傍晚的海滩,光线自然一些",Agent 自动拆解任务、选择工具、逐步执行、最后返回成品图。这背后的产品逻辑是:修图工具从"把人当操作员"变成"把人当提出需求的人"。
从项目层面看,选"Agent + 修图"这个组合还有一个更务实的原因:AI 图像处理模型本身已经很成熟了,但散落成单个 API 可调用能力时价值有限,一旦用 Agent 把它们编排起来,用户表达的自然语言需求就能自动映射到一段工具调用链上,产品的壁垒从"有没有 AI 能力"变成了"能不能把 AI 能力组合成完整工作流"。这一点对全栈开发者尤其重要,因为它意味着后端不再是简单的 CRUD,而是要做任务调度、状态管理、异步处理和结果校验。
1.2 Vue3 + Golang + UniApp 三端选型,各自承担什么责任
技术栈的选择是我一开始想了很久的问题。后端选 Golang 主要看重三点:并发性能、部署简单、生态成熟。修图 Agent 场景里用户会频繁提交图片处理请求,后端要同时处理 WebSocket 长连接推送任务状态、HTTP 上传接口、以及异步任务队列,Golang 的 goroutine 模型在这种 IO 密集型场景下非常舒服,写起来也不会像回调地狱那样恶心。部署上直接交叉编译成单个二进制文件扔到服务器上就跑,对个人开发者来说省心不少。
前端 Web 端用了 Vue3 + Vite + Pinia。坦白说,Vue3 和 React 在这个项目里选哪个都行,我选 Vue3 是因为组合式 API 在管理图像上传、任务状态轮询、画布预览这种强交互场景下写起来更顺手,而且模板语法在处理多端条件渲染时更直观一些。移动端则用 UniApp 实现一套代码跑 H5、小程序和 App。这种选型有一点要提前做好心理准备:UniApp 的跨端能力是"同一套代码,不同端有不同表现",而不是"写一次到处完美",后面我会详细说踩坑细节。
后端内部不是一个大单体,而是拆成了 API 服务和 Agent 调度服务两个进程。API 服务负责鉴权、图片上传、任务查询,Agent 调度服务负责接收任务、拆解执行计划、调用图像处理工具链、更新任务状态。拆开的原因很现实:图像处理模型的推理时间普遍在 5 到 30 秒之间,这种耗时任务如果放在请求链路里同步执行,用户早就跑了。拆成独立服务后,API 接口立即返回任务 ID,客户端轮询或通过 WebSocket 订阅进度,Agent 调度服务在后台异步处理,整体体验流畅很多。
1.3 Agent 架构里我用到的核心概念和组件
Agent 这个词这两年很火,但落到项目里到底是怎么设计的,很多人其实有点模糊。我这里的 Agent 调度服务实现了一个简化的"规划-执行-观察"循环:
- 规划(Planning):接收用户自然语言指令,通过大模型的 Function Calling 能力将意图解析成结构化的步骤流,比如"人像抠图 -> 背景替换 -> 色彩统一"
- 执行(Execution):按步骤调用对应的工具函数,每个工具对应一种具体图像处理能力
- 观察(Observation):每个步骤拿到结果后,校验输出是否符合预期,模型可以根据中间结果决定下一步是继续、调整参数还是终止
这整个流程里最核心的是两件事:一是大模型 Function Calling 的稳定性,二是工具层的抽象设计。Function Calling 如果调用参数经常出错,后面的流程全乱套;工具层如果不抽象好,每接一个新模型就要改一遍调度代码。
关于工具抽象,我写了一个统一的图像工具接口,每个工具只需要实现三件事:声明自己的名称和作用、定义输入的 JSON Schema、提供执行函数。这样 Agent 调度器完全不需要关心工具内部是调用云端大模型 API 还是本地 OpenCV,只需要按照 Schema 传参并接收返回值。
2. 核心功能拆解与关键实现细节
2.1 Agent 任务编排:自然语言如何变成修图步骤
用户输入"把图片背景换成沙滩"之后,系统需要经历一个意图解析阶段。我在这里用的方案是大模型 + Function Calling 输出结构化 JSON,而不是让它直接返回自然语言再解析。核心原因是结构化输出更可控。
我给大模型定义了这样一组工具方法:
understand_image(image_url):分析图片内容,返回画面元素、主体位置、光线方向等信息matting(subject_desc):对主体进行分割抠图,返回带透明通道的 PNGreplace_background(scene_desc, style_desc):根据描述生成新背景,并和前景合成adjust_color(target, params):调整饱和度、色温、对比度等参数check_quality(image_url):对结果进行质量评估,输出是否达标
当用户输入"把这张照片的背景换成傍晚的海滩,光线自然一些",大模型解析后输出的工具调用链大致是这样的:
[ {"tool": "understand_image", "args": {"image_url": "xxx"}}, {"tool": "matting", "args": {"subject_desc": "人物主体"}}, {"tool": "replace_background", "args": {"scene_desc": "傍晚的海滩", "style_desc": "自然光线"}} ]注意,这只是一个简化示例,实际场景中模型还可能会加上一步check_quality来做结果检查。整个调用链形成后,Agent 调度器就按顺序执行。关键的参数回填在这里很重要:第二个步骤matting输出的透明背景 PNG 路径,要能自动变成第三个步骤的输入参数的一部分。这个"步骤间数据传递"我用的是一个共享上下文对象,所有工具都可以读写,大模型在规划时只需要声明依赖前序步骤的哪个输出字段即可。
2.2 图像处理能力:云端模型 API 与本地图像算法的取舍
图像处理能力是这个项目最重的部分,也是资金消耗最大的部分。我最终采用的是"云端模型 API 为主、本地算法为辅"的混合方案。
云端模型 API 主要负责三类任务:抠图、背景生成/替换、图像质量评估。这三个任务对模型能力要求高,本地跑小模型效果差距明显,直接用云端 API 最省心。以抠图为例,我用的是端到端的人像分割模型,返回带 alpha 通道的 PNG,不需要任何后处理就能直接用。
本地算法负责的是那些用 OpenCV 就能解决的任务,比如格式转换、尺寸裁剪、基础色彩调整、添加文字水印。这些操作如果用大模型做,成本高且速度慢,但用传统图像处理算法做,毫秒级就能完成。整个项目做下来我最大的感受是:不要什么问题都甩给大模型,传统算法在确定性任务上又快又稳。
混合方案还需要考虑图片在工具链之间怎么传递。云端模型 API 输入输出通常都是 URL,所以我把上传的图片先存入 OSS,生成一个可访问的 URL。抠图接口返回的透明 PNG 也先上传 OSS,再把 URL 传给下一步。图片多跳转几次 OSS 会有点慢,但我实测下来同一地域的 OSS 内网传输延迟很低,属于可接受范围。
2.3 多端能力复用:UniApp 的跨端策略和我的妥协
移动端用 UniApp 是为了省成本,这是实话。一套 Vue 代码能编译到 H5、微信小程序和 App,对于个人开发者来说省了至少一半工作量。但跨端不是免费的午餐,我在开发过程中做了不少妥协。
第一个妥协是画布预览组件。本来想用一个功能比较强的图像编辑 Canvas 库,但发现这个库在小程序端不支持。后来我换成了两端都兼容的方案:Web 端用一个开源 Canvas 编辑器封装成组件,小程序端直接跳转到一个内置的图片预览页面,裁剪和基础调整用原生 API 实现。虽然功能弱了一些,但保证了核心链路在两端都能跑通。
第二个妥协是上传方式。H5 端可以直接用<input type="file">拿到 File 对象,小程序端必须通过wx.chooseImage+uni.uploadFile才能上传。我在封装层做了一个上传适配器,统一暴露chooseAndUpload()方法,内部根据平台走不同逻辑。
第三个妥协是 WebSocket 的兼容性。UniApp 在小程序端的 WebSocket API 和 H5 端实现有细微差别,比如小程序断线重连需要自己处理心跳。我在 App 端最终放弃了 WebSocket,改成轮询任务状态,原因是小程序的 WebSocket 在切后台后经常被系统杀掉,恢复时机不可控,轮询反而更省心。
3. 实操过程与核心环节实现
3.1 数据库设计与任务状态机
这个项目的数据库表不多,但任务状态机的设计我花了不少心思。核心表就三张:user用户表、task任务表、task_log步骤日志表。
task表的关键字段包括:id、user_id、input_image_url、output_image_url、status、current_step、steps_json、created_at、updated_at。其中steps_json存的是大模型规划出来的完整步骤流,current_step记录当前执行到第几步。这样设计的好处是,即使 Agent 调度服务在执行途中崩溃,重启后也能根据这两个字段恢复任务进度,不需要从头再来。
状态机的流转我定义为:
PENDING -> RUNNING -> SUCCEEDED | \ | -> FAILED v CANCELED为什么加一个CANCELED状态?因为用户很有可能在任务执行到一半的时候后悔。比如背景生成太慢,用户点了取消,这时候如果后端还在继续调用云端 API,既浪费钱又没必要。我的处理方式是:任务执行循环里,每一步开始前都会检查一下 Redis 中的任务标记是否被置为取消,如果是就直接终止并返回已取消状态。
3.2 Agent 调度器的核心循环实现
Agent 调度器的核心循环是整个项目的心脏。我用伪代码描述一下工作流程,方便理解:
func (a *Agent) Run(task *Task) { steps := parseSteps(task.StepsJSON) ctx := NewContext() ctx.Set("input_image", task.InputImageURL) for i, step := range steps { // 检查任务是否被取消 if a.isCanceled(task.ID) { task.UpdateStatus("CANCELED") return } task.UpdateCurrentStep(i, step.ToolName) result, err := a.ExecuteTool(step.ToolName, ctx, step.Args) if err != nil { task.UpdateStatus("FAILED") a.LogError(task.ID, step, err) return } // 将结果写回上下文,后续步骤可以引用 ctx.Set(step.OutputKey, result) task.LogStep(task.ID, step, result) } task.UpdateStatus("SUCCEEDED") task.UpdateOutput(ctx.Get("output_image")) }这里有几个实操中很重要的细节。
第一是超时控制。任何一个云端模型 API 都可能因为网络问题或服务端负载高而卡住。我给每个工具执行都加了独立的 context 超时,抠图类任务给 60 秒,模型生成类给 120 秒,超时后整个任务直接标记失败,并记录是哪个步骤超时。这样排查问题的时候能精准定位到具体工具,而不是笼统地看到"任务失败"。
第二是重试策略。对于偶发的网络错误,直接判定失败有点浪费。我用了一个简单的指数退避重试:第一次失败等 2 秒重试,第二次失败等 4 秒,最多重试 3 次。但如果报错是参数错误(比如图片 URL 无法访问)或者鉴权失败,那重试也没有意义,直接返回错误。
第三个是上下文的数据量控制。因为每一步工具的输出可能是一张图片 URL、一个 JSON 对象或者一段文本,如果全量塞进上下文传入大模型做下一步规划,Token 消耗会非常夸张。我的方案是:上下文里永远只保留最近两步的结果摘要和图片 URL,不保留完整数据。这样既限制了 Token 消耗,也避免上下文过长导致模型理解偏差。
3.3 前端交互与任务进度展示
用户端的交互流程是这样的:用户在 Web 或小程序里上传一张图片,在输入框里用自然语言描述想要的修图效果,点击生成。前端立即创建任务,拿到任务 ID,然后开始轮询任务状态接口。每轮询一次,拿到current_step和steps_json,前端根据当前步骤名称展示对应的提示文案——比如正在抠图就提示"正在分离主体",正在生成背景就提示"正在绘制新背景"。
这里有一个很影响体验的细节:不要把步骤名直接展示给用户。用户看到"matting"这种词会一脸懵,但展示"正在智能识别画面主体,请稍候"就友好得多。我在后端任务表里额外存了一个step_display_name字段,由大模型在规划时顺便生成一个面向用户的中文短句,前端直接展示这个字段就行。
轮询接口的设计也需要注意性能。并发高的时候,用户每 2 秒请求一次任务状态,如果每次请求都查数据库,压力不小。我的优化方案是:任务状态变更时同步写入 Redis,轮询接口优先读 Redis,只有当 Redis 中没有数据时才回源数据库。实际压测下来,这个优化能把接口的 QPS 支撑能力提升好几倍。
3.4 基于用户反馈的并发与性能优化
项目上线后,我陆续做了几轮并发性能优化,这里挑最有价值的两个记录一下。
第一个是图片预处理的并发控制。一开始我以为并发瓶颈会在 Agent 调度器上,后来通过日志发现,真正卡住的是 OSS 上传和内网图片拉取的 IO 操作。同一个用户如果同时提交了多张图片批量处理,串行上传会非常慢。我改成了上游并发处理,限制最大并发为 5。可别小看这个改动,批量修图的场景下,整个任务链路的耗时从原来的"图片张数乘以单张耗时"变成了"图片张数除以 5 再乘以单张耗时",体感快了很多。
第二个是结果缓存的按时失效策略。用户可能对同一张图片反复测试不同风格的背景提示词。如果每次提交都重新走一遍抠图和生成流程,成本不可控。我的做法是根据图片内容的 MD5 值做一层结果缓存:同样一张输入图片,如果只是背景描述文字变了,抠图步骤的结果可以直接命中缓存,省下最贵的模型调用费用。实测下来,这个优化至少帮我省了 30% 的 API 成本。
4. 开发与上线过程中的问题排查实录
4.1 大模型输出不稳定的处理方案
开发过程中骚扰我最久的问题就是大模型的 Function Calling 输出不稳定。明明给的是同一个工具定义,用户类似的请求,有时候模型能正确输出结构化的调用参数,有时候却死活不按 Schema 来,要么多了一个字段,要么少了一个必填项,甚至偶尔还会把工具名拼错。
我试过几种方法,最终有效的是组合拳:
- 把工具定义的
description写得更像人话,明确说明字段的取值范围和单位。比如背景描述字段,原来只写"场景描述",后来改成"用 20-50 个字描述要替换成的场景,包含光线、时间、环境元素" - 在用户原始指令前拼接一段系统提示词,明确指出"如果用户的修图需求不明确,需要先调用 understand_image 获取图片信息,再决定后续工具"
- 在解析大模型响应时做 JSON Schema 校验,失败就自动重试一次,并带着校验错误信息让模型修正。这一步实测能把解析成功率从 85% 提到 97% 左右
4.2 图像处理链路中的典型错误与修复记录
图片格式兼容问题。不同云端模型 API 对输入图片的格式和大小要求不一样。有的要求最大边不超过 1024 像素,有的要求必须是 JPEG 或 PNG。我一开始没做统一预处理,导致用户上传一张 5MB 的 HEIC 图片传给抠图接口时直接报错。后来我在任务创建阶段强制做了一次标准化:把图片统一转换为 JPEG 格式,最大边缩放到 2048 像素以内,质量压缩到 85%。这个预处理能用 OpenCV 高效完成,增加的开销可以忽略不计,但兼容性问题基本消灭了。
结果图与原图尺寸不一致。背景替换类任务容易出这个问题:模型生成的背景图尺寸和原图不一致,导致合成后的图片出现拉伸或裁切变形。我后来在合成阶段加了一步智能居中缩放:先获取前景透明图的原始宽高,再根据背景图尺寸计算缩放比例,保证前景主体比例不变,然后居中对齐合成。这里用到的就是最基础的计算几何原理,但要提醒一点:缩放前一定要先抠图,先缩放后抠图的边缘质量会变差。
透明通道信息丢失。有一次合成结果整体发白,排查发现是中间步骤将透明 PNG 转成 JPEG 导致 alpha 通道丢失。这个问题很隐蔽,因为抠图结果本身看起来没有异常,但走到合成步骤时前景的透明区域全变成了白色。修复方案是在内部传递中间结果时统一使用 PNG 格式,只有最终输出给用户时才转成 JPEG。这算是一个典型的"格式转换安全"问题,以后我在设计内部接口时都会明确标注允许的图片格式。
4.3 任务失败但前端无感知的体验优化
上线初期,用户反馈最多的问题是"点了一下没反应"。查日志发现其实是任务已经创建了,但 Agent 调度服务在处理时因为某个工具调用失败,任务直接变成了 FAILED,而前端没有明显提示,用户以为没反应。
我在前后端各做了一步优化。后端在任务失败时不仅标记状态,还会把失败原因和失败步骤的用户友好文案存入任务表,比如"背景生成服务暂时不可用,请稍后重试";前端轮询发现状态是 FAILED 时,不再只是静默刷新,而是弹出一个明确的错误提示框,展示失败原因,并提供"一键重试"按钮。重试的本质其实是重新创建一个任务,但复用原来的输入图片和用户指令,所以对用户来说体验是连续的。
另外,我加了任务超时的兜底逻辑。如果某个任务 RUNNING 状态超过 10 分钟还没结束,后端会主动标记为 TIMEOUT,避免任务永久卡在运行中占用资源。
4.4 多端兼容性问题的经验沉淀
UniApp 的兼容性问题在项目后期集中爆发。小程序端对 Canvas 的底层实现和 Web 端差异很大,我一开始写了一个混合模式的绘制方案,在 Web 端用canvas的 2D 接口,小程序端判断运行环境后改用小程序专用的 Canvas API。这个方案代码重复度高,但胜在可控,避免了一堆环境判断的复杂逻辑。
还有一个很典型的问题是小程序端的图片下载和临时文件管理。小程序不允许直接使用一张网络图片作为 canvas 绘图素材,必须先调用uni.getImageInfo或uni.downloadFile将网络图片下载到本地临时文件,再绘制。这个过程如果网络差,非常容易失败。我最后在下载图片的地方加了一层本地缓存,同一张图片第二次使用直接走缓存,大幅降低了失败率。
这里我给要在 UniApp 里做图像处理的同学一个建议:先摸清目标端的 Canvas 能力边界,再决定功能范围。我一开始想当然地以为 Web 端能跑通的 Canvas 功能小程序端都能跑,结果后来花了好几天改适配,得不偿失。
5. 上线后的数据表现与进一步优化思路
5.1 项目上线后的实际表现和我的一些验证数据
项目上线运行了大约一个月后,我统计了一些关键数据。累计处理任务 8000 多次,任务成功率约 91%,剩下的 9% 里大部分是云端模型 API 偶发超时,真正因为代码逻辑错误导致的失败不到 2%。平均任务完成时间大约是 8 秒左右,其中抠图大约 2 到 3 秒,背景生成大约 4 到 5 秒,合成和预处理各 1 秒左右。这个响应速度在用户可接受范围内,毕竟不是实时滤镜,而是有生成过程的场景。
任务成功率是我比较在意的指标。从最初的 80% 出头的成功率提升到现在的 91%,核心变化其实不是在模型层,而是在工程层——重试机制、超时控制、预处理标准化这几项加上的效果非常明显。很多 AI 应用看起来是模型不行,实际是工程保障没做到位,这是我在这个项目里最大的收获之一。
5.2 从"能用"到"好用"还可以做的优化方向
项目完结不代表没有优化空间了。我复盘后觉得还有三个方向值得继续做下去。
第一个是增加用户手动修正结果的能力。现在用户如果对 Agent 生成的背景不满意,只能重新输入一遍指令再试一次,比较低效。如果能在前端增加一个"基于当前结果继续调整"的功能,把当前结果图作为上下文传回 Agent,用户就可以说"背景再暗一点"或者"人物往左挪一点",形成一个真正的多轮交互闭环。
第二个是引入并行的步骤编排。目前的 Agent 执行计划是严格的串行链路,但有些情况下多个工具之间并没有依赖关系,比如同时调整色彩和添加滤镜。如果能识别出可并行的步骤,整体耗时还能再缩短。这需要大模型在规划时输出一个 DAG(有向无环图)形式的执行计划,而不是简单的数组,实现复杂度会有明显提升,但收益也直观可见。
第三个是接入流式结果输出。目前用户必须等整条任务链路跑完才能看到结果,如果能在 Agent 执行完关键的抠图步骤后,先把半成品图推送给用户预览,用户的等待焦虑会明显缓解。这其实是一种"实时过程可视化"的产品思路,前端 WebSocket 已经具备推送条件,后端需要把中间结果图也标记为可访问状态并推送给客户端。
6. 写在最后的几个心得
这个项目完结后,我最大的感觉是:全栈 AI Agent 项目的复杂度不在于某一个技术点有多难,而在于把多条技术链路串起来的时候,哪里都可能掉链子。
如果你准备做一个类似的项目,我的建议是:先把任务状态机设计得足够清晰,再把工具层抽象做得足够简单,最后再考虑接入更多 AI 能力。状态机决定了你的系统能多稳,工具层决定了你能多快地接新模型,AI 能力反而是最容易被替换的部分。
我个人在实际开发中还有一个体会:别舍不得用云端 API,也别什么都依赖云端 API。像抠图、背景生成这种重模型任务,用云端 API 是最省时间的选择;但像格式转换、压缩、基础色彩调整这些确定性操作,本地算法又快又省钱。把这两类任务分清楚,你的项目成本和体验都能兼顾。
最后再分享一个实用的小技巧。Agent 调度器里每一步执行完之后,把该步骤的输入参数和输出结果都记录到日志表里。这件事一开始看起来只是普通的日志记录,但等到项目上线后排查问题的时候,你会发现这个日志表就是排查问题最好的线索来源。不要相信自己的记忆,要让系统帮你记住每一步发生了什么。
项目做完了,但这个方向我还会继续折腾。AI Agent 的编排能力,加上图像处理这种直观可见的反馈场景,组合起来还有很多玩法可以挖掘。如果你也在做类似的全栈 AI 项目,随时欢迎一起交流踩坑经验。