ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

用Go搭建AI Agent流水线:从商品图自动生成淘宝详情页

用Go搭建AI Agent流水线:从商品图自动生成淘宝详情页 1. 为什么我用 Go 来搭这条 AI Agent 流水线而不是 Python1.1 这条流水线到底在解决什么问题上个月接了个挺现实的需求运营团队每天要上新几十个品每个品都要配一整套淘宝详情页——标题、卖点、规格参数、场景文案、详情模块一套下来少说要半小时写多了以后你会发现内容还高度同质化。甲方问了我一句话能不能丢一张商品图进去自动把详情页给我生成出来我当时的第一反应和大家一样用 Python 写 Agent毕竟 AI 生态的 SDK、教程、开源项目基本都是 Python。但冷静下来算了一笔账这本质上不是一个“AI 能力”问题而是一个“工程编排”问题。图片上传、预处理、调用多模态模型、解析 JSON、并行生成多段文案、模板渲染、打包输出这些环节里没有一个是 Python 独有的反而是 Go 的强项。最后这版从 1 张商品图到一整套淘宝详情页的流水线我用 Go 从零搭完前后端一起跑生产环境吃并发比我预期中稳得多。这篇文章就聊聊这条流水线怎么设计、怎么落地以及我在实际开发中踩过的坑。你能看到完整的数据流设计、流水线编排思路、并发与限流方案、还有结构化输出的兜底处理。如果你正打算做类似的 AI Agent 项目或者想知道 Go 到底适不适合写 Agent这篇应该对你有用。1.2 为什么选 Go不是抬杠是算过账的先摆结论AI 模型本身的能力和你用什么语言无关。模型 API 就是 HTTP 接口你给我 JSON我给你 JSONGo 调用起来和 Python 一样顺手。真正的差异在工程侧。我整理过一张对比表直接说结论维度GoPython并发模型goroutine channel轻量直接协程/asyncio心智负担高部署单二进制拉起来就能跑依赖环境、pip、虚拟环境麻烦内存静态类型长驻服务友好脚本进程一多内存容易飙升JSON 处理encoding/json 性能好方便但慢AI SDK 生态一般但调用 API 足够丰富但很多用不到运维监控原生可观测亲和要额外接框架关键要理解一点Agent 流水线里的瓶颈通常是外部模型的响应时间不是语言本身的执行速度。真正的工程难点是怎么把几十个请求编排好、重试好、限流好、观测好。这些恰恰是 Go 的主场。我线上跑了一周单机 8 个 worker 并发处理图片每天处理几百个商品没有一次因为语言性能出问题。还有一点很多人忽略如果你已经有一个 Go 写的电商后台用 Go 写 Agent 意味着可以直接嵌入现有服务共享配置中心、日志系统、链路追踪不需要再起一个 Python 微服务来“伺候”它。实际维护成本会低很多。1.3 流水线整体设计每个环节都是独立的“工位”整条流水线我拆成了六个阶段每个阶段只做一件事输出给下一个阶段商品图 → 图片预处理 → VLM 结构化抽取 → 标题/卖点/详情段落并行 → 模板渲染 → 打包输出为什么要拆这么细三个理由第一每一段都能独立重试和观测。哪一步慢、哪一步挂了日志里一目了然。第二中间产物可以缓存。商品图处理过一次抽取结果就不会再变不需要每次都重新调模型烧钱。第三将来换模型供应商时只需要替换对应阶段的实现流水线骨架完全不用动。我在设计时给每个阶段定义了一个标准接口后面会详细说。总之记住这句话流水线不是把代码写成一长串顺序调用而是把每个环节都做成可以单独替换、单独测试的“工位”。2. 核心链路拆解从一张图到结构化商品信息2.1 图片预处理先压图再进模型很多第一次做多模态 Agent 的人会直接把原图丢给 VLM这是最容易翻车的点。淘宝的商品图动辄几 MB甚至还有高分辨率大图直接传进去不仅模型侧 token 消耗大接口超时率也会直线上升。我在流水线最前面加了一个预处理环节做三件事等比缩放到最长边不超过 1024 像素同时保持清晰度统一转成 JPEG 格式避免遇到 HEIC、PNG 大图时模型 API 不支持或解析过慢顺便做一次基础的图片方向纠正减少 VLM 识别时的误判。代码也很简单用 Go 的image标准库加上github.com/disintegration/imaging就能搞定func preprocessImage(srcPath string) (string, error) { src, err : imaging.Open(srcPath, imaging.AutoOrientation(true)) if err ! nil { return , err } dst : imaging.Fit(src, 1024, 1024, imaging.Lanczos) tmp, _ : os.CreateTemp(, prep-*.jpg) err imaging.Encode(tmp, dst, imaging.JPEG, imaging.JPEGQuality(90)) if err ! nil { return , err } return tmp.Name(), nil }注意不要做超过 1024 的无脑压缩否则商品细节比如面料纹理、产品型号丢失后面抽取出来的属性会不准。我实测 1024 是一个性价比比较高的值VLM 能看清细节token 也控制得住。预处理输出的这张标准图会贯穿整个流水线所有后续模型调用都用它避免不同环节看到不同图片导致信息对不上。2.2 VLM 结构化抽取让模型输出 JSON而不是让它“自由发挥”预处理完接下来的核心是把一张图变成结构化的商品信息。这里我用的不是普通 LLM而是多模态大模型VLM。你可以用 GPT-4o、Qwen-VL、或者国内各种支持图片输入的模型底层逻辑是一样的把图片和一段强约束的 Prompt 一起发给模型让它输出固定结构的 JSON。Prompt 我反复迭代过最后长这样你是资深电商运营请分析这张商品主图只做信息提取不做文案创作。 输出 JSON格式如下 { category: 商品类目, attributes: { 颜色: ..., 材质: ..., 形状: ..., 型号: ... }, selling_points: [卖点1, 卖点2, 卖点3], target_users: 目标人群描述 } 要求 1. 只依据图片中可见信息不要脑补。 2. 忽略图中的水印、促销文字、logo 等干扰信息。 3. attributes 只输出视觉可见的属性最多 5 个。 4. selling_points 不超过 5 条每条不超过 20 字。这里有一个容易被忽略的细节不是所有模型 API 都原生支持 JSON 模式。能开结构化输出比如response_format{type: json_object}的尽量开不能开的就必须做解析兜底。我在流水线里写了三级降级先尝试用 JSON mode不行就靠后处理把模型回复里的 JSON 块抠出来再不行就重试。下面这段解析函数在线上救了我很多次func extractJSON(raw string) ([]byte, error) { // 去掉 json 和 包裹 re : regexp.MustCompile((?s)(?:json)?\\s*(.*?)) if m : re.FindStringSubmatch(raw); m ! nil { return []byte(m[1]), nil } // 去掉首尾可能的非 JSON 文本 start : strings.Index(raw, {) end : strings.LastIndex(raw, }) if start 0 end start { return []byte(raw[start : end1]), nil } return nil, fmt.Errorf(no json block found) }解析成功后再json.Unmarshal到预定义的结构体。这一步的输出是整个流水线的“黄金中间产物”后面所有文案生成都基于它不再直接看图既省钱又稳定。2.3 结构化输出的校验与兜底模型给的数据也不能全信模型再强也会偶发幻觉。比如图片里根本没有“材质”信息它硬编一个“纯棉”出来后面的标题就跟着一起错了。所以我在抽取阶段之后加了一个校验层做三件事字段缺失检测关键字段category、attributes缺失就重试一次枚举校验类目必须在我预先维护的类目表里不在就做相似度匹配或者抛错长度校验selling_points 每条超过 20 字就截断避免后续文案结构崩掉。这块逻辑看起来琐碎但非常重要。流水线越往后走错误越会被放大。你在第一步让模型“猜”了一个不存在的属性后面生成的整段文案都会围绕这个假属性展开像滚雪球一样最后出来的详情页根本不能看。所以我情愿在早期多花一次重试的成本也不愿意让脏数据流到下游。3. 流水线编排Go 里的状态流转与并发控制3.1 数据模型设计一个 Context 贯穿全程流水线的核心数据结构我设计成了PipelineContext所有阶段共享它每个阶段只修改其中属于自己的字段type ProductInfo struct { Category string json:category Attributes map[string]string json:attributes SellingPoints []string json:selling_points TargetUsers string json:target_users } type Section struct { Title string json:title Content string json:content } type PipelineContext struct { RequestID string ImagePath string ProcessedImage string Product ProductInfo Title string SellingPoints []string Sections []Section HTML string StageDurations map[string]time.Duration Meta map[string]any }有人可能会问为什么不直接用context.Context传递数据我的做法是用标准库的context.Context传递取消信号和超时控制用PipelineContext传递业务数据两者职责分离互不混用。RequestID字段特别重要每个请求进来先赋一个 UUID整个流水线的日志、模型调用参数、错误信息都带上它排查问题的时候才知道是哪一单、哪一步出了问题。3.2 把每个环节做成接口而不是一坨顺序代码流水线调度器我定义了一个Stage接口type Stage interface { Name() string Run(ctx context.Context, pc *PipelineContext) error }然后实现一个最简单的 Runnertype Pipeline struct { stages []Stage } func (p *Pipeline) Add(s Stage) *Pipeline { p.stages append(p.stages, s) return p } func (p *Pipeline) Execute(ctx context.Context, pc *PipelineContext) error { for _, s : range p.stages { start : time.Now() if err : s.Run(ctx, pc); err ! nil { return fmt.Errorf(stage %s failed: %w, s.Name(), err) } pc.StageDurations[s.Name()] time.Since(start) slog.Info(stage finished, req, pc.RequestID, stage, s.Name(), cost, time.Since(start).String()) } return nil }接口的好处是想加一个“文生图”环节写个新 Stage 塞进去就行想换掉某个模型供应商只改对应 Stage 的内部实现。每个 Stage 可以单独写测试、单独 mock不需要把整条链路都跑起来才能验证。3.3 哪里该并行哪里必须串行流水线不是所有环节都能并行的。回到这条链路上图片预处理 → VLM 抽取这两步必须串行因为抽取依赖预处理结果抽取完之后标题生成、卖点生成、详情段落生成这三者互不依赖可以并行最后模板渲染又必须等前面都完成。所以我用一个errgroup把三个独立的生成任务并发跑起来var g errgroup.Group g.Go(func() error { return genTitle(ctx, pc) }) g.Go(func() error { return genSellingPoints(ctx, pc) }) g.Go(func() error { return genSections(ctx, pc) }) if err : g.Wait(); err ! nil { return fmt.Errorf(parallel generation failed: %w, err) }实测效果很明显串行时单条详情页生成需要 25 秒左右并行优化后压到 12 秒以内其中大头还是卡在外部的两次模型调用上。这里提醒一个新手容易踩的坑errgroup默认会在第一个错误返回时取消其它 goroutine但你调用的模型 API 不一定响应 context 取消。如果确实需要“等所有子任务跑完再统一判断”就用errgroup.WithContext配合或者干脆自己用sync.WaitGroup加错误通道自己实现别默认它一定会立刻中断。3.4 断点续跑把中间结果缓存下来线上跑了一段时间后我发现模型接口偶尔不稳定重试了三五次还是失败。如果从图片预处理重新开始等于把已经花掉的模型调用费用和等待时间又烧一遍。所以我加了一个很关键的能力阶段级缓存。实现思路很简单每个 Stage 的输出都以RequestID StageName 参数hash为 key 缓存到本地磁盘或 Redis。如果后续阶段失败重新拉起流水线时Runner 检查到某个阶段的产物已经存在就直接跳过从失败点接着跑。func (r *cachedRunner) runStage(ctx context.Context, s Stage, pc *PipelineContext) error { key : cacheKey(pc.RequestID, s.Name(), stageInputHash(s, pc)) if data, ok : r.cache.Get(key); ok { return json.Unmarshal(data, pc) // 恢复阶段输出 } if err : s.Run(ctx, pc); err ! nil { return err } data, _ : json.Marshal(pc) r.cache.Set(key, data, 24*time.Hour) return nil }这个机制对重试、对批量任务的重跑省下的时间和成本非常可观。尤其是在大促前批量生成商品页的场景里哪怕 10% 的任务失败重跑时大部分中间产物都能命中缓存整体完成时间能缩短一半以上。4. 模型接口的稳定工程超时、重试、限流一个都不能少4.1 超时和重试要分级不是所有错误都值得重试调用外部模型 API 是流水线里最容易出问题的环节。我把超时分成两档连接超时10 秒超过直接判定失败整体响应超时VLM 调用给 120 秒普通 LLM 给 60 秒。Go 的http.Client可以直接设置不需要额外框架client : http.Client{ Timeout: 120 * time.Second, Transport: http.Transport{ DialContext: (net.Dialer{Timeout: 10 * time.Second}).DialContext, MaxIdleConns: 100, }, }重试策略我也做过分级整理429、5xx重试但要退避指数退避 随机抖动400、401、403不重试立刻返回错误这是你代码或者配置的问题超时重试一次如果第二次还是超时直接放弃这一单进入失败队列。指数退避的代码很简单但随机抖动很重要。不加热抖动的话批量任务同时超时重试会在上游 API 形成第二次“惊群”把限流打得更死func nextBackoff(attempt int) time.Duration { base : time.Duration(math.Pow(2, float64(attempt))) * time.Second jitter : time.Duration(rand.Int63n(int64(base / 2))) return base jitter }4.2 并发保护Agent 扛并发卡点在上游限流“AI Agent 怎么扛并发”这个问题我当时的答案是先把你自己的代码扛住再去处理上游。Go 的 goroutine 很便宜真正危险的是同时发出大量模型请求把自己的上游 key 打爆。我的做法是用一个带缓冲的 channel 作为信号量限制同时进行的模型调用数量var sem make(chan struct{}, 5) // 最多 5 个并发模型请求 func callModel(ctx context.Context, payload string) (*Result, error) { select { case sem - struct{}{}: defer func() { -sem }() case -ctx.Done(): return nil, ctx.Err() } // 实际 HTTP 调用 return doCall(ctx, payload) }这个信号量的大小不是拍脑袋定的我是先查了模型服务的限流文档再压测调整。如果你的上游限制是每分钟 60 次那信号量设为 5、配合 12 秒左右一个请求比较稳。另外建议把“模型服务”单独抽象成一层统一处理鉴权、限流、重试、prompt 组装。别在 Stage 里散落着各种裸 HTTP 调用否则换供应商的时候你会想哭。4.3 可观测性每个阶段都要有“账本”排查 Agent 流水线问题最痛苦的是不知道哪一步慢、哪一步挂了。我从一开始就用 Go 标准库的log/slog打结构化日志每条日志带上RequestID和StageName。下面是典型的一条time2025-01-08T14:22:1008:00 levelINFO msgstage finished reqa3f9e1 stagevlm_extract cost8.2s tokens1243线上排查的时候我一般先用grep reqa3f9e1拉出某一单的所有日志看它在哪个阶段耗时最长、报了什么错再决定是调 Prompt 还是调超时参数。这一步省下的时间比任何“AI 技巧”都实在。如果你有 Jaeger 或者别的链路追踪系统给每个 Stage 加个 span 也不难但千万别在最开始就上重量级方案先用 slog 把事情记清楚等真正需要再演进。5. 从结构化数据到淘宝详情页模板渲染与成稿5.1 详情页的构成标题、卖点、参数、模块一个不少淘宝详情页看起来长其实结构是固定的。我拆成了五个标准模块模块内容来源生成方式商品标题基于属性拼装 后处理LLM 生成候选规则校验核心卖点VLM 抽取的 selling_points直接使用 句式润色规格参数attributes直接渲染成表格详情描述场景文案、使用说明LLM 分模块生成页面包装整体 HTML/CSSGo 模板引擎渲染这里有个经验不要让 LLM 直接输出整段详情页 HTML。大模型写 HTML 容易结构混乱而且毫无必要。应该让模型输出干净的结构化文案渲染的事交给 Go 的html/template这样既能保证页面样式统一又能避免模型输出注入非法标签。我用 Go 标准库html/template写了几个模板比如卖点模块const sellingPointsTpl ul classpoints {{range .SellingPoints}} li{{.}}/li {{end}} /ul模板是预编译的执行速度飞快一个页面渲染耗时基本在 1 毫秒内比模型调用消耗的时间少几个数量级。这就是我把渲染放在流水线最后一环的原因前面的模型调用再慢最后的拼装也必须快到可以忽略不计。5.2 标题的“字数与关键词”后处理SEO 不是玄学淘宝标题有一个硬性限制60 个字符以内而且前 12 个字基本决定了搜索结果里的展示效果。让 LLM 自由发挥的标题经常超长或者关键词堆砌得不像人话。我加了一个后处理函数超长截断按“核心词 属性词 场景词”的优先级保留去除违禁词比如“最”、“第一”、“国家级”等广告法违禁词类目兜底如果标题里没有类目词比如“连衣裙”自动追加一个。func normalizeTitle(raw string, category string) string { title : stripForbiddenWords(raw) title truncateByRune(title, 60) if !strings.Contains(title, category) { title category title } return title }注意这里用truncateByRune而不是简单的title[:n]因为中文是 UTF-8 编码按字节切会把汉字截断成乱码。这是很多 Go 新手写中文处理时最容易忽略的细节。5.3 打包输出不只是 HTML而是一整个“素材包”最后一环我把生成结果打包成一个标准目录包含三样东西detail.html可直接预览的详情页data.json结构化商品信息方便运营后台二次编辑assets/预处理后的商品图、以及各模块需要引用的图片资源。打包用archive/zip标准库几十行代码就能搞定。之所以做成压缩包而不是只给 HTML是因为运营拿到后可以直接解压上传到商品编辑后台省掉手工复制粘贴的步骤。这一步也让我意识到Agent 流水线的最终交付物不一定是“一篇文案”更可能是一套可以进入现有业务系统的资产。设计流水线时多想想下游怎么用而不是只盯着模型输出。6. 实测过程、踩坑记录与问题速查6.1 端到端实测数据我用一张典型的电商商品图一款保温杯跑了一遍完整流水线各阶段耗时如下阶段模型耗时说明图片预处理无480ms本地压缩VLM 结构化抽取多模态模型8.1s最大耗时点标题生成LLM2.3s并行执行卖点生成LLM2.1s并行执行详情段落生成LLM4.8s并行执行模板渲染无10ms本地执行打包输出无50ms本地执行总耗时约 18 秒但并行优化后实际墙钟时间在 12 秒以内。这个速度对批量生成场景完全够用毕竟人工写一套详情页要半小时起步机器 12 秒出初稿运营再花几分钟修改就能上架。值得注意的是耗时大头 100% 都在外部模型调用上。这也再次印证了我的判断语言选型不是瓶颈模型调用链路才是。6.2 我踩过的五个坑第一图片太大导致 token 爆炸。最开始没加预处理一张 4000×3000 的商品图直接传上去不仅慢费用也高。加了缩放到 1024 之后效果几乎无损成本降了 70%。第二VLM 把包装上的字当成了卖点。商品图上有“限时特惠”“热卖”这类水印文字模型很容易当成商品信息抽取出来。后来在 Prompt 里明确加了一条“忽略图中的水印、促销文字、logo 等干扰信息”问题才缓解。第三模型输出里带json包裹。即使你让它“只输出 JSON”它偶尔也会礼貌地给你包上 Markdown 代码块。没有兜底解析的话json.Unmarshal必然报错。所以我写了上面那个extractJSON函数先去代码块再去首尾花括号。第四标题超长且带违禁词。LLM 生成的标题经常超过 60 字符还动不动冒出来“最”“纯天然”“顶级”这些词。发了几个线上版本之后被运营反馈我才把后处理那套逻辑加上去。现在这条规则已经沉淀成流水线的标准环节了。第五上游限流导致批量任务连环失败。一次性提交 50 个商品时50 个 goroutine 同时打模型 API直接触发限流。后来加了信号量限流 指数退避批量任务的完成率从 60% 提升到 99% 以上。6.3 排查技巧与问题速查表我把平时遇到最多的问题整理成了一个速查表方便你遇到类似情况直接对号入座现象可能原因排查手段模型返回空内容prompt 太长/输入图无效检查预处理输出缩短 promptJSON 解析失败模型输出带 markdown用 extractJSON 兜底接口超时图片过大确认预处理阶段已执行批量任务大量 429并发超过上游限流调小信号量加退避重试标题乱码按字节截断中文改用 rune 截断生成内容与图不符VLM 幻觉校验阶段重试一次或调整 prompt详情页样式崩LLM 直接输出 HTML改为只输出结构数据模板渲染排查问题有个基本原则先看日志再猜原因。我的日志里每一单都有RequestID每一个阶段都有耗时。遇到线上问题第一步永远是grep reqxxx拉出整条链路的时间线而不是凭感觉调 prompt。这样定位问题通常不超过五分钟。6.4 一个能救命的调试技巧重放工具最后分享一个我压箱底的小技巧。线上任务失败后很多临时文件比如预处理后的图片、模型返回的原始响应会随着进程退出而丢失导致你没法复现。我后来给流水线的失败分支加了一个“证据收集器”任务失败时自动把当前PipelineContext、模型原始响应、错误堆栈一起序列化成一个 debug 包存到专门的目录。调 bug 的时候直接写一个小工具加载这个 pack就能在本地完整重放当时的现场不需要再调一次模型、烧一次钱。这看起来是个不起眼的工程细节但在真实项目中它节省的调试时间可能比整个流水线开发时间还多。我个人在实际开发中的体会是这类 AI Agent 流水线项目真正决定成败的往往不是模型选得多强而是工程细节有多扎实图片处理、JSON 解析、超时重试、限流、日志、缓存每一步都在给你的系统“续命”。把这一圈地基打好后面换更强的模型、加新的场景都只是换个 Stage 的事。
返回列表