← 返回博客

一个人做 AI 产品,我踩过的 9 个真实的坑

AI 在图里写甲骨文、同参数重试纯烧钱、429 把流水线打瘫、模型评委爱放水、批量任务连环烧钱直到装了断路器……9 个坑,每一个都对应一段现在还在跑的代码。

一个人做 AI 产品,我踩过的 9 个真实的坑

系列说明:这是「AI 详情页生成工具」系列的第 3 篇,踩坑复盘。前两篇分别讲了架构(多模型 Agent 流水线)和搭建(Wails + Go + Vue3),这篇全是血泪:每一个坑都对应项目里一段现在还在跑的代码。文中代码节选自项目源码,有删减。

先交代背景:我一个人做的桌面端 AI 工具(ecom-detail-ai),上传 1 张商品图,自动跑一条"读图→规划→生图→文案→排版→自检"的流水线,产出整套淘宝详情页。LLM 按 token 计费、生图按张计费,全是用户自己的账号出钱。这个业态决定了踩坑的两大主题:幻觉和钱。

坑 1:让 AI 在图里写字,它给你一屏甲骨文

现象:详情页大量依赖文字排版(标题、卖点、参数标注)。早期我按直觉写提示词:"画面左上角写上'轻松收纳'四个艺术字"。Seedream 和 GLM-Image 交出来的图,字形扭曲、笔画乱炖,放大看像甲骨文他舅——生成式模型画文字至今依然是重灾区,而且越"艺术字"越完蛋。

原因:扩散模型不是"写字",是在"画字的形状",字符结构对它来说就是纹理。你禁不掉它画糊。

解法:彻底把文字从 AI 手里收走,三层设防:

  1. 规划提示词写入铁律,场景描述不得携带"画文字"的意图:
const planNoTextRule = "画面中禁止出现任何文字、数字、参数标注、标语或标签贴纸;" +
    "prompt 只描述商品、场景、氛围与构图,不得要求画面书写、展示或标注任何文字" +
    "(商品自身印刷的文字除外,那部分会被逐字保留)。"
  1. 渲染品质段再显式禁一次。注意第一版措辞是"无文字水印",模型可能理解为"不要水印"然后照画文字——禁令必须精确。现在的写法:"画面中不得出现任何文字、数字、标注、标签贴纸(商品本身印刷的文字除外)"。
  2. 真实文字由本机 Go 排版引擎用系统 CJK 字体后期叠加。

反直觉的点:加了这个"AI 画面零文字"约束后,出图质量的方差明显变小了。文字是生成图上最容易露怯的元素,拿掉它,整体观感反而更"贵"。

下面是修复后的实际效果——AI 只负责画面(干净得一个字都没有),参数表和所有文字都是本机排版引擎用真实字体叠上去的:

详情切片:参数表与文案由排版引擎叠加,画面区无任何 AI 生成文字

详情切片:参数表与文案由排版引擎叠加,画面区无任何 AI 生成文字。

坑 2:同参数重试是烧钱行为,重试必须带诊断

现象:自检环节发现图不合格就重试一次,听起来很合理。但第一版重试是原样重跑,结果第二张和第一张犯一模一样的错——商品左上角的提手照样变形。

原因:同样的 prompt + 同样的模型,采样温度再高也只是换种方式错。

解法:自检输出结构化诊断标签(product_drift / structure_deform / light_off / text_error / scene_mismatch),重渲染时把上一轮的失败原因翻译成人话回灌进 prompt:

// 同参数重跑没有意义:重试必须带上上一轮的自检诊断
prompt := basePrompt
if lastCheck != nil {
    prompt += "\n注意:上一轮生成存在问题:" + checkTagsSummary(lastCheck) +
        "。重新生成时务必修正,其余要求不变。"
}

"商品与原图不一致、光影违和"这种具体指控,模型是听进去的。同类思想也用在了首屏双候选上:两个候选必须是不同构图(规划构图 vs 正面平视),不同参数的候选才叫候选,同参数抽两次卡叫赌博。

坑 3:429 限流把整条流水线打瘫

现象:新注册的图像生成账号 RPM 低到感人(glm-image 初期几乎只能串行),一晚上批量测试,任务大面积失败,报错全是"速率限制"。

原因:Agent 流水线各环节的调用频率天然不均,生图一步连发 N 张,直接撞墙;撞墙后代码里的错误判断又识别不出这是"可以等的错"还是"该死的错"。

解法:错误特征识别 + 指数退避 + 可取消的等待:

var rateLimitBackoff = []time.Duration{10 * time.Second, 20 * time.Second,
    40 * time.Second, 60 * time.Second}

func isRateLimitError(err error) bool {
    msg := err.Error()
    return strings.Contains(msg, "速率限制") || strings.Contains(msg, "rate limit") ||
        strings.Contains(msg, "429") || strings.Contains(msg, "RequestLimitExceeded") ||
        strings.Contains(msg, "Throttling")
}

三个细节:退避等待必须用 select 同时监听 ctx.Done(),否则用户点了取消还得干等 60 秒;退避状态要推进度事件("触发限流,20 秒后重试(2/4)"),用户看到会理解,看不到会以为程序死了;默认渲染并发直接锁 1,等账号配额养起来再调。另外各家的限流错误文案五花八门,isRateLimitError 这种"错误特征识别"函数要留好持续补词的空间。

坑 4:模型评委爱放水,服务端必须设硬门禁

现象:自检提示词让视觉模型当质检员判 pass/fail,模型非常"自信",肉眼可见商品变形了,它照样给 pass:true。

原因:多模态模型当判官有系统性的乐观偏置,你问它"这图合格吗",它倾向于说合格。

解法:不信任模型的结论,只信任模型的评分素材。模型输出保持度分数和诊断标签后,服务端再做一道确定性门限:

// enforceCheckGate:模型判 pass 之外再做一道硬校验
const checkFidelityGate = 0.95

func enforceCheckGate(r CheckResult) CheckResult {
    for _, tg := range r.Tags {
        switch tg {
        case TagProductDrift, TagStructureDeform, TagTextError:
            r.Pass = false   // 硬伤 tag 一票否决
        }
    }
    if r.Fidelity > 0 && r.Fidelity < checkFidelityGate {
        r.Pass = false
    }
    return r
}

判分标准和提示词里写死的规则("fidelity<0.95 或出现硬伤 tag 时 pass=false")两边对齐,模型就算口头放水,数字和标签也拦得住。这个"LLM 打分 + 确定性门限"的三明治结构,适用于一切用 LLM 做评估的场景。

坑 5:批量任务连环烧钱,直到我装了"断路器"

现象:内测同事导入了 60 个 SKU 的批量任务,密钥过期了。第一个任务鉴权失败,第二个、第三个……六十个任务排着队把能调的接口挨个调挂,钱和情绪双双受损。

原因:任务队列对"系统性失败"和"个别失败"没有区分能力。个别任务失败是常态,但同一批次连续失败几乎必然意味着环境问题(密钥失效、余额耗尽、断网)——这时候继续跑就是纯烧钱。

解法:调度器里加了一个断路器,规则简单粗暴——同批次连续 3 条 failed,整个批次自动暂停:

const batchConsecFailPause = 3

switch task.Status {
case "done":
    s.batchFail[task.BatchID] = 0        // 成功一次,计数清零
case "failed":
    s.batchFail[task.BatchID]++
    if s.batchFail[task.BatchID] >= batchConsecFailPause {
        s.db.Model(&model.GenerationBatch{}).Where("id = ?", task.BatchID).
            Update("status", "paused")   // 停止该批出队
    }
}   // canceled 不计入连续失败

三个口径设计:成功一条就清零(说明只是偶发失败);用户取消不计入(那是主动行为不是故障);暂停不杀进程,已付费跑完的任务照常结算,只拦后续出队。UI 上给"疑似密钥/余额问题"的提示,用户检查后点继续,队列重新入队恢复。任何会自动消耗真金白银的批处理系统,都应该在第一天就装这个断路器。

坑 6:状态机的竞态,白烧一张图 0.1 元(事小,脸被打得疼)

现象:偶发一种诡异日志:任务状态是 canceled,但产物目录里躺着刚生成的图。用户在"排队中"点了取消,任务还是跑了。

原因:调度器出队后 go runTask(id),而 runTask 开头要查库、读配置,有好几毫秒窗口;用户恰好在这个窗口点取消——取消逻辑看任务还没跑起来,直接改库置 canceled,dispatcher 也根本不知道要拦这个 goroutine。两边都没错,合起来错了。

解法:把"开始执行"做成一次条件更新,用数据库解决竞态:

// 条件更新(仅 pending 才置 running):保证「排队中取消」必然生效
res := s.db.Model(&model.GenerationTask{}).
    Where("id = ? AND status = ?", taskID, "pending").
    Updates(map[string]interface{}{"status": "running", "started_at": nowMillis()})
if res.RowsAffected == 0 {
    return   // 已被取消或已到终态:不执行、不产生任何成本
}

谁先改库谁赢,输家看到 RowsAffected == 0 安静退出。取消排队中任务同样用条件更新(WHERE status='pending' 才置 canceled),双向都安全。教训:进程间/协程间的状态仲裁,与其靠内存锁和时序推理,不如把数据库当线性化寄存器用——桌面应用自带 SQLite,这资源不用白不用。

坑 7:本地文件当服务入参,被目录穿越和文件消失教育了两次

现象 A:前端要预览数据目录里各处的生成图,我挂了个 /local/ 静态服务,安全同学(我自己)一眼看到没做路径清洗——/local/../../etc/passwd 全家桶。 现象 B:用户创建任务后把商品原图挪走了/重命名了,重试时报"商品图不存在"。任务还活着,素材先死了。

解法:A:/local/ handler 统一 filepath.Clean 后强制校验 strings.HasPrefix(full, dataDir+separator),不在目录内一律 404。B:创建任务时就把商品图复制进数据目录(内容 sha1 去重),任务永远引用入库副本;导入失败才降级用原路径(只影响缩略图,不阻塞任务)。

顺带一提第三方服务的坑:阿里云商品分割接口只吃公网 URL,本地文件得先转存它的临时 OSS,返回的结果图 URL 也是带过期签名的临时地址,必须立即下载转存本机。对接任何云 API,先问自己三个问题:入参收不收本地文件?返回的链接活多久?endpoint 是哪个区域的(imageseg 只有上海域名,抄文档写北京直接查无此域名)。

坑 8:一个人写代码,架构靠自觉是不可能的

现象:项目快速迭代两周后,我发现 app.go(Wails 绑定层)里开始出现直接调 gorm 的函数,internal/agent 包里开始有人(还是我)import database。分层在 PPT 上,不在代码里。

原因:单人项目没人 review,"就这一次破例"是常态。写进文档的规范保质期约等于零。

解法:把架构规则编译成会在 CI 里变红的测试。项目里的 internal/guard/ 包用 Go AST 解析源码做断言:绑定层不得 import gorm、model 层必须是叶子依赖、新依赖必须登记进 deps.yaml 且双向校验(清单里有的必须真实存在,存在的必须登记)。

护栏本身还有一条元规则,是护栏工程最精髓的一句话:

任何用正则/AST 解析源码做断言的护栏,解析到 0 个结果必须报错,而非当作"通过"静默放行。

否则你把 app.go 改名了,"app.go 不许碰数据库"这条护栏就会在零解析的状态下天天报绿——护栏瞎了,比没有护栏更危险,因为它提供虚假的安全感。所以每条护栏找不到解析目标时的报错文案都是统一的:"写法可能已变更,请同步更新护栏解析规则"。

坑 9:批量导入的"垃圾进垃圾出",被一个 CSV 编码教做人

现象:内测用户兴冲冲导入 60 个 SKU,任务列表里商品名全是"鍟嗗搧"这类火星文;还有人导入后一半行离奇消失,来问是不是 bug。

原因:国内 Excel/WPS 另存 CSV 默认 GBK 编码,按 UTF-8 解析自然全是乱码;"行消失"则是图片路径列缺失或相对路径基准搞错,数据被静默跳过了。

解法:与其做编码嗅探(GBK/UTF-8 误判率不低,判错就是整批数据污染),不如把规则收紧并显性化——只收 UTF-8,检测到非法编码直接报错提示"另存为 CSV UTF-8 后重试";相对路径明确以 CSV 所在目录为基准;最重要的一个设计是先预览再落库:弹确认层展示有效行数、被跳过的行及原因、整批预估金额,用户确认后才建任务,且单批上限 200 行。批量入口的原则:宁可让用户多点一次确认,也不给他一个"静默吃错数据"的黑盒。

尾声:一些数字和一句总结

  • 单套 5 图详情页实测约 8 分半,成本折算几毛钱到一块多(token + 出图张数逐任务落库,界面明示估算值);
  • 以上 9 个坑里,绝大多数发生在有真实用户之前的内测期,值钱;
  • 最大的一条心得:和 LLM 协作的全部工程智慧,浓缩成一句话就是"别信它的输出,接管它的手脚"——输出必须过 schema 解析和确定性门限,手脚必须限定在结构化 JSON 和白名单事实源里,它负责聪明,你的代码负责靠谱。

最后放一张"接管手脚"的集大成产物——白底主图。它压根不走生成模型:抠图产物直接铺白,本机合成,零成本零变形:

白底主图:抠图产物本地铺白合成,不走生成模型

白底主图:抠图产物本地铺白合成,不走生成模型。

系列前两篇:第 1 篇《我用 Go 搭了一条 AI Agent 流水线》、第 2 篇《Wails v2 实战:用 Go + Vue3 做一个真正能用的 AI 桌面应用》。欢迎聊聊你踩过的 AI 产品的坑。

有类似的事要管?

说说现在是怎么做的——Excel、微信群、纸质单子都行。我判断能不能做、大概怎么做。

聊聊你的需求