1. 项目背景与整体设计思路
最近我在线上环境里接了一个 gpt-image-2.5 图像生成模型,用 Spring Boot 3 搭了一套完整的生图管道,还做了比较重的防刷限制。整个过程从接口封装、异步任务、文件存储,到限流、配额、验证码,踩了不少坑,也沉淀了一些可以复用的经验。这篇东西不是官方文档,更像是我做完这个项目后的复盘笔记,适合已经会用 Spring Boot、但第一次接 AI 生图接口的后端同学,也适合正准备把“能生图”变成“敢上线”的团队参考。
如果你只是想在本地调通一个图片接口,那你直接请求上游模型服务就行了,五分钟能搞定。但一旦要考虑真实流量、成本控制、恶意调用、图片落盘、状态回调和错误重试,事情就完全不一样。这就是我写这篇文章的核心原因:把一条简单的 HTTP 请求,变成一条可靠的、可控的、能防刷的工业级生图管道。下面我先把整体思路拆开讲,然后再带你把代码一步步写完。
1.1 这个项目到底解决了什么问题
先想清楚一个问题:为什么不能在前端直接调图像生成模型?因为模型服务要走密钥、要计费、要对生成内容负责,而且不能把密钥暴露给浏览器。所以必须有一个后端服务做中转,统一接收请求、校验权限、调用上游、处理图片文件、再异步通知用户。这就是“生图管道”存在的基础。
第二个问题是异步。图像生成通常不是毫秒级返回,往往要三秒到十几秒,甚至更久。如果用同步请求,一个用户可能一直转圈,网关时间一长还会超时。所以我把“提交请求”和“生成完成”拆开:用户提交后立刻拿到一个任务 ID,后面通过轮询或者回调拿到结果。这个模式很像外卖订单:你下单后拿到单号,不需要一直盯着厨房,做完自会通知你。
第三个问题是成本与安全。生图是真实消耗 tokens 或者按张计费的,如果有人刷接口,几小时就能烧掉一大笔钱。因此防刷不是“上线后再说”的功能,而是从架构第一版就嵌进去的硬约束。后文会有单独一章讲防刷,这里先记住结论:生图服务比普通 CRUD 接口更需要防刷,因为每一次请求都在花真金白银。
1.2 技术选型:为什么是 Spring Boot 3 而不是 Python FastAPI
现在很多 AI 服务的示例代码都是 Python 写的,因为算法团队和模型生态都在 Python 侧。但工程落地是另一回事。如果你们公司现有的用户体系、网关、监控、配置中心都是 Java 技术栈,那你完全没必要为了接一个生图模型再拉一套 Python 服务。我这次选 Spring Boot 3,核心原因有三点:
第一,生态统一。用户鉴权、Redis、MQ、定时任务、链路追踪这些东西在 Java 生态里非常成熟,可以直接复用现有的基础设施。Spring Boot 3 基于 Spring Framework 6,Jakarta EE 风格,JDK 17 起步,性能和并发能力足够撑住生图场景。
第二,线程模型更可控。生图是 IO 密集 + 外部 API 调用,Spring Boot 3 的异步机制结合虚拟线程(Virtual Threads)可以大幅降低线程阻塞成本。即使不用虚拟线程,传统的 ThreadPoolTaskExecutor 也足够用,而且管控起来很直观。
第三,团队维护成本低。如果你所在的后端团队全是 Java 背景,强行引入 Python FastAPI 等于给运维和开发同时增加负担。不是说 FastAPI 不好,它是很好的工具,但工程选型要看整体最优解,而不是单点最优解。FastAPI 的生态在轻量接口和机器学习服务里非常顺手,而 Spring Boot 更适合做完整的业务闭环。
所以我的建议是:如果这个项目只是纯算法 demo,用 FastAPI 写个薄封装就很舒服;如果要接入真实业务、用户体系、账单和风控,放在 Spring Boot 3 里是更稳的选择。
1.3 一条完整生图管道的五个环节
我习惯把生图管道拆成五个环节,每一环都要独立可测试,出了问题也好定位。
- 接入层:接收 HTTP 请求,做基础参数校验、鉴权、限流。
- 任务层:把请求转成内部任务 ID,丢进异步队列,立刻返回任务状态。
- 调用层:由执行器消费任务,调用 gpt-image-2.5 上游模型接口,拿到图片数据或 URL。
- 存储层:把图片落盘到本地磁盘或者对象存储,生成可访问的 URL。
- 通知层:通过 webhook 或者轮询接口把结果通知给调用方。
这五个环节里,最容易出事的是调用层和存储层。上游不稳定、返回格式变化、图片二进制流不完整、存储路径权限不对,都会让任务状态出现偏差。我在后面会把每一层的细节、参数、坑都展开讲,你可以直接照着抄。
2. 核心细节解析:参数、任务与存储
2.1 先理解模型 API 的交互方式
我这次接的 gpt-image-2.5 上游模型服务,对外暴露的是 HTTP 接口,风格上和 OpenAI Images API 非常像,都是 POST 一个 JSON,然后返回状态和图片信息。多数云厂商的生成模型现在都长这样:你把 prompt、尺寸、格式、质量参数发过去,它返回一张图片的 base64 字符串或者一个临时下载 URL。
这里有个非常重要的点:上游返回的是二进制还是 URL,直接决定你的存储逻辑。返回 base64,你就自己解码写文件;返回 URL,你就需要再发起一次下载。这两个方案我都用过,个人建议优先使用 base64,因为临时 URL 往往有有效期,而且还要多一次 IO 和网络请求,万一上游服务器临时不可用,图片就丢了。
典型的请求体长这样:
{ "model": "gpt-image-2.5", "prompt": "一只戴宇航员头盔的橘猫,高清,未来感", "n": 1, "size": "1024x1024", "quality": "standard", "response_format": "b64_json" }响应体一般是:
{ "created": 1711234567, "data": [ { "b64_json": "/9j/4AAQSkZJRgABAQAAAQ..." } ] }我在封装客户端时的原则是:上游返回不可信任。你永远要对自己的 DTO 做防御性校验,比如data数组为空、b64_json为空字符串、字段大小写不一致,这些都是实际会发生的坑,不是理论风险。
2.2 prompt 与参数的最佳实践
模型生成质量很大程度上取决于 prompt 和参数,这看起来是“产品”问题,但工程侧也要考虑参数约束。如果不做任何限制,用户传了一个超长 prompt 或者一个非法 size,你转发给上游,轻则返回报错,重则消耗了额度却生成垃圾图。
我在接入层做了这么几个校验:
- prompt 必填,长度控制在 1 到 1000 个字符,超长直接拒绝。
- size 只允许白名单:
1024x1024、768x768、512x512,其他一律打回。 - quality 只允许
standard和hd,默认standard。 - n 表示一次生成几张,我限制最大 4。这个参数必须防,因为 n 是线性放大成本的。
- response_format 固定为
b64_json,不接受外部传入。
还有一个容易被忽略的点:图片内容安全。上游模型可能对某些敏感 prompt 有自己的审核规则,但你不能完全依赖它。我在管道里接了一个简单的敏感词过滤,虽然不完美,但能在源头挡住一批明显不合规的请求。这块不是道德洁癖,而是合规要求,内容生图类产品尤其要注意。
2.3 同步转异步:线程池与任务队列的选择
生图接口如果做成同步,第一是用户体验差,第二是网关超时。我采用的方案是“内存任务队列 + 线程池”,简单可靠,适合单机部署。
当然,如果你有多个实例,就必须用 Redis + Redisson 或者消息队列来做分布式任务。但这篇文章先讲单机版本,便于理解核心逻辑,后面扩展分布式再单独写。单机版本的异步组件三个:任务存储、线程池、任务状态管理。
任务存储我用的是 ConcurrentHashMap,key 是任务 ID,value 是任务状态对象。这个方案在单机、任务量不大的场景下非常香,不需要引入额外中间件。但要注意:任务状态要放在内存里的同时定期清理,否则会内存泄露。我会在常见问题里讲这个坑。
线程池用 Spring 的ThreadPoolTaskExecutor,核心参数可以参考下面的配置:
core-size: 8 max-size: 16 queue-capacity: 200 keep-alive-seconds: 60这里要特别强调一下拒绝策略。默认的AbortPolicy在任务队列满的时候会直接抛异常,这对用户请求来说就是 500。我更推荐CallerRunsPolicy,让提交任务的那个线程自己执行任务,虽然会阻塞当前请求,但至少不会丢任务。如果希望彻底削峰,可以接消息队列做缓冲,这是后话。
2.4 图片文件存储和访问路径设计
图片生成完成后,不能一直留在内存里。我选择先解码 base64,然后写入本地磁盘目录,同时生成一个可访问的 URL。存储路径的规划很有讲究,一个好的路径规则可以帮助你后续排查问题。
我用的规则是:
/data/ai-images/{yyyyMMdd}/{taskId}.png为什么这么设计?第一,按天分目录,避免单个目录文件过多;第二,用 taskId 做文件名,关联任务记录,排查问题时直接通过任务 ID 找到图片;第三,后续接对象存储时,这个路径直接可以作为 OSS 的 key,迁移成本很低。
生成图片 URL 的时候,我在application.yml里配置了一个image.base-url,比如https://api.example.com/image,然后拼上相对路径。注意:不要把本地磁盘绝对路径泄露给前端,否则会有安全问题。你只需要返回一个内部资源接口,由 Spring 静态资源映射或者对象存储网关来转发。
3. 实操过程:从零搭一个可运行的管道
3.1 项目初始化与 Maven 依赖
我用的是 Spring Boot 3.2.4,JDK 17,Maven 构建。最小的依赖就要这几个:
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.2.4</version> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webflux</artifactId> </dependency> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> </dependency> </dependencies>这里我引入了webclient而不是RestTemplate,原因很简单:WebClient 是 Spring WebFlux 里的响应式 HTTP 客户端,底层用 Netty,天然支持超时控制、流式处理和更好的并发表现。虽然在 Spring Boot 3 里你仍然可以用RestTemplate,但我实测下来 WebClient 在大并发下更稳。
3.2 application.yml 配置与静态参数管理
先定义配置项,把可变的东西全部外置:
spring: application: name: ai-image-service server: port: 8080 app: image: base-url: http://localhost:8080/image storage-path: /data/ai-images max-pending-tasks: 200 upstream: base-url: https://api.example.com/v1/images/generations api-key: ${UPSTREAM_API_KEY} connect-timeout-ms: 3000 read-timeout-ms: 30000 async: core-size: 8 max-size: 16 queue-capacity: 200 keep-alive-seconds: 60 rate-limit: capacity: 10 refill-per-second: 2这里特别注意:api-key千万别写死在代码里,我用了环境变量占位${UPSTREAM_API_KEY},你在部署环境里注入即可。这个配置在整个管道里被很多地方引用,属于核心配置文件。
3.3 客户端封装:与上游模型服务通信
封装上游调用时,我专门建了一个ImageClient类,统一处理超时、重试、异常。核心方法大致如下:
public ImageGenerationResponse generate(ImageRequest request) { return webClient.post() .uri(upstreamBaseUrl) .header(HttpHeaders.AUTHORIZATION, "Bearer " + apiKey) .contentType(MediaType.APPLICATION_JSON) .bodyValue(request) .retrieve() .bodyToMono(ImageGenerationResponse.class) .timeout(Duration.ofMillis(readTimeoutMs)) .retryWhen(Retry.backoff(2, Duration.ofMillis(500))) .block(); }注意,block()方法会阻塞,在 Spring MVC 里没问题,但如果你用了虚拟线程,效果更好。这里我用了retryWhen做重试,只针对网络异常,而对 4xx 错误不重试,因为参数错误重试多少次都没用。
还有一个细节:上游接口返回的内容可能很大,一个 1024x1024 的 PNG 转 base64 后有接近 2MB。所以 WebClient 的内存缓冲策略要调大,默认可能是 256KB,会导致数据截断。我当时的教训就是生出一张 0 字节图片,排查半天发现是缓冲区不够,后面再说。
3.4 异步执行器:核心任务处理逻辑
任务执行器是管道的灵魂,它负责从内存队列拿任务,然后调用上游、解码、写文件、更新状态。我实现了一个ImageTaskExecutor,核心逻辑是:
public void execute(ImageTask task) { try { task.setState(State.RUNNING); ImageGenerationResponse response = imageClient.generate(buildUpstreamRequest(task)); byte[] imageBytes = decodeBase64(response.getData().get(0).getB64Json()); String filePath = storageService.save(task.getTaskId(), imageBytes); task.setResultUrl(buildResultUrl(filePath)); task.setState(State.SUCCESS); // 回调通知放在 finally 或单独方法中 } catch (Exception e) { task.setError(e.getMessage()); task.setState(State.FAILED); // 失败重试逻辑 } }这里有两个关键点。第一,base64 解码之前要判断字符串是否带data:image/png;base64,前缀,有些上游实现返回这个前缀,有些不会,必须做兼容。第二,即使任务失败也不能直接把异常吞掉,要把错误信息记录到任务对象里,方便查询。
3.5 对外接口:查询、回调与取消
对外接口我提供了三个:提交生图、查询任务、回调查询。提交接口返回任务 ID 和状态;查询接口按 ID 返回任务详情;回调则是给调用方一个 URL,任务完成后用 HTTP POST 通知。
提交接口的 Controller 不直接执行任务,而是先校验,然后创建任务 ID,丢进线程池:
@PostMapping("/api/image/generations") public ResponseEntity<SubmitResponse> submit(@RequestBody @Valid SubmitRequest request) { String userId = authService.getCurrentUserId(); if (!rateLimitService.tryAcquire(userId)) { return ResponseEntity.status(429).build(); } ImageTask task = taskService.create(request, userId); asyncExecutor.execute(task); return ResponseEntity.ok(SubmitResponse.of(task.getTaskId())); }查询接口就很简单,从内存任务表里取状态。但要注意:任务完成之后不能一直躺在内存里,我会用定时任务每隔一段时间清理已经超过一小时且状态为终态的任务,防止 OOM。
3.6 部署与压测验证
我部署的时候直接打成 jar,放在一台 4C8G 的机器上,用 Nginx 做了 TLS 终结和反向代理。压测工具用的是 JMeter,压测之前我先做了逻辑验证,再用 100 并发连续调了十分钟,重点看线程池的数量和拒绝率。
压测发现最初的线程池配置偏小,任务队列满了之后大量请求 503。后来我把核心线程数调到了 16,队列容量加到 500,并调整了限流参数,问题才缓解。压测给组内还产出了一个结论:单机这一套配置,在 100 并发以内、单图生成时间 5 到 10 秒的情况下,吞吐量是够用的,没必要一开始就上消息队列和对象存储。
4. 防刷架构:工业级应用的生命线
4.1 生图服务的成本模型决定了防刷的必要性
普通接口被刷,最坏结果是数据库连接耗尽,服务器宕机,恢复就行。生图接口被刷,是每分钟都在消耗上游额度,而且生成一张图可能让 GPU 推理好一会儿,对上游也有压力。更麻烦的是,如果刷量脚本拿到你一个合法的 token,它可以无限循环生成图片,你的账单会在一晚上变成六位数。所以防刷不是“锦上添花”,而是这个服务的刚需。
我在设计防刷时遵循一个原则:越靠近客户端,防刷越要简单粗暴;越靠近上游,防刷越要精细精准。也就是说,网关层可以做 IP 限流、设备指纹、验证码;服务层要做用户态鉴权、配额扣减、并发限制;调用上游之前还要做一次最终的额度检查,宁可拒绝也不能亏本。
4.2 第一道防线:API Key 与签名校验
先明确:AI 生图服务不能像开放平台那样只靠一个 Bearer Token 走天下。因为生图代价高,你必须知道“谁在调用、是否合法、有没有配额”,否则出了问题无从追溯。
我做了两层签名机制:
- 对内部客户端,要求请求头里带
X-Client-Id和X-Signature。签名规则是HMAC-SHA256(secret, timestamp + path + body),同时校验时间戳,时间偏差超过 60 秒直接拒绝。这个能防大部分简单重放攻击。 - 对最终用户,走登录态的 userId,通过现有用户体系签发短期 token。短 token 设计成 2 小时有效,避免用户长期占用配额后转卖。
这套签名代码不复杂,但能挡住大量非专业刷手。真正高级的刷手会专门逆向你的签名算法,那就需要配合后文的风控和验证码来拦截。
4.3 第二道防线:多级限流与配额控制
限流我实现了三个层级,每层都是独立的令牌桶:
- 全局限流:整个服务每秒最多接受 N 个生图请求,我配的是 2 个每秒,超出的排队或拒绝。这是为了防止上游接口被打爆。
- 用户限流:每个用户每分钟最多生成 X 张,我配的是 5 张每分钟。这个参数要根据业务调,太严影响体验,太松防不住刷量。
- 并发限制:同时最多有 M 个任务在处理,我用线程池的队列长度来天然限制。
令牌桶的 Redis 实现有很多现成的 Lua 脚本,我这里给一个简单版的思路:用 Redis 的INCR和EXPIRE做固定窗口计数,结合RedissonRateLimiter做分布式限流。如果你只是单机,用 GuavaRateLimiter就够了。
配额控制则是按账号维度统计月度用量,每次提交任务前检查余额。我做了一个简单但有效的年费预扣机制:任务提交时先预扣一定额度,任务失败后再退还。不然等到生成完成才扣费,容易被并发请求把额度刷超。
4.4 第三道防线:验证码与设备指纹
到了验证码这层,说明前面已经识别出可疑流量。我对两类情况强制验证码:
- 同一 IP 在短时间内提交超过阈值,比如 1 分钟超过 10 次。
- 新注册用户首次调用,或者用户设备指纹异常。
设备指纹的实现方式很多,我用的朴素方案:前端在 header 里带上一个设备 ID,这个 ID 由前端 SDK 生成,再加上 IP、User-Agent、屏幕参数,服务端组合后哈希。不需要引入重型 SDK,够用就行。
验证码我强烈推荐直接用滑块或者点选,不要搞图形字母验证码,因为现在 OCR 识别太强了,形同虚设。滑块验证码的效果也不是万无一失,但至少提高了刷量成本。
4.5 风控数据采集与异常告警
最后一步是把所有防刷信息记下来。不是为了导出报告,而是为了实时发现异常。我在每次请求的关键节点埋点,打一条结构化的日志,包含 userId、IP、设备ID、请求参数、耗时、是否命中限流、是否失败。日志统一推到 ELK,再配几个告警规则:
- 同一 userId 在 5 分钟内生成失败超过 10 次。
- 同一 IP 在 5 分钟内触发限流超过 100 次。
- 单用户单小时配额消耗超过 30 张。
一旦触发告警,自动把相关用户拉入灰名单,灰名单用户只能通过验证码放行,并且并发限制降到极低。这个机制在运营层面非常有效,比单纯封号温和,也不会误伤偶尔频繁使用的正常用户。
5. 常见问题与排查技巧实录
5.1 上游返回超时,任务状态却是成功
这是我最开始遇到的一个诡异问题。上游连接超时,WebClient 抛异常,但任务状态却被更新成了 SUCCESS。查了半天发现,异常发生在decodeBase64之前,而我的catch块虽然捕获了异常,却在状态更新那里写错了顺序,导致状态被后面的成功逻辑覆盖了。
解决方式很简单:状态更新用状态机约束,只有RUNNING -> SUCCESS或RUNNING -> FAILED,并且把成功状态的赋值放在所有操作完成之后,不允许中途赋值。你也可以用AtomicBoolean来防重入,但根本上要理清代码路径。
5.2 线程池满了导致拒绝请求
线程池满的时候实际发生的现象不是拒绝,而是请求超时或者 500。排查方法很直接:看日志里有没有TaskRejectedException,以及监控线程池活跃数。我当时看到线程池queue一直处于满的状态,就说明消费速度跟不上生产速度。
解决办法有几个方向:增大核心线程数、增加队列容量、提高限流级别、优化上游调用耗时。不能只调一个参数。我先用压测数据算出了平均单图耗时,再乘以预期并发,倒推线程池配置。这个公式很好用:core = QPS * avgLatency / (1 - buffer),其中 buffer 是缓冲比例,一般留 20% 到 30%。
5.3 生出来的图片是 0 字节
这个坑最初让我以为是存储问题,其实出在 WebClient 的内存缓冲。上游返回的 base64 字符串动辄几 MB,如果 WebClient 的默认内存限制不够,数据会被截断,解码出来的字节数组是空的,写文件就得到 0 字节。
我调整了 WebClient 的codecs:
@Bean public WebClient webClient() { return WebClient.builder() .codecs(configurer -> configurer.defaultCodecs() .maxInMemorySize(10 * 1024 * 1024)) .build(); }10MB 足够应付 1024x1024 的 PNG。如果你的业务要生成更大尺寸的图,再把上限往上调,但也要注意内存占用。
5.4 回调通知重复或丢失
回调通知本身也是 HTTP 接口,同样有超时、重试、重复投递的问题。我在通知环节用了本地消息表,任务完成后先把待回调的记录插入数据库,状态为PENDING,然后投递。投递失败则下次定时任务重试,最多三次。接收方要做幂等,通过 taskId 去重。
这里的教训是:不要直接在任务执行线程里做回调,会拖长任务执行时间;也不要只靠内存中的任务状态记录,进程一重启就丢了。用一张简单的task_notify表,配合定时任务,是最稳妥的。
5.5 限流把正常用户挡在外面
有一次上线后用户反馈“生成图片一直转圈”,我一看日志,发现用户限流参数被配成了每分钟 1 张。这个参数本意是防止用户连续刷图,但实际上一个普通用户通常要多轮调整 prompt 才能生成满意图片,每分钟 1 张太苛刻。
最终我把用户限流调成了每分钟 5 张,同时把全局限流从每秒 2 个提到每秒 5 个,并对连续失败的任务做慢速重试,才算平衡。限流参数一定要有业务依据,最好能通过 AB 测试来做,不要拍脑袋。
5.6 DEBUG 利器:日志和链路追踪
整个管道跨了 HTTP、线程池、文件存储多个环节,靠单个日志文件很难排查。我在 pipleline 的所有入口和出口都打印了结构化日志,统一带上traceId和taskId。用 Spring Cloud Sleuth 或者 Micrometer Tracing 都可以,但即使没接框架,你也应该手动生成一个 traceId 放在 MDC 里。
日志格式建议这样:
[taskId=3a12ef, phase=submit, userId=u101] 收到生图请求, size=1024x1024 [taskId=3a12ef, phase=upstream, latency=5421ms] 上游返回成功 [taskId=3a12ef, phase=store, latency=120ms] 图片写入 /data/ai-images/... [taskId=3a12ef, phase=notify, latency=80ms] 回调成功有了这条链路,任何一个环节耗时异常或者失败,都能在 30 秒内定位到具体节点,而不是靠猜。
做完这个项目,我最大的感受是接入一个 AI 生图模型并不难,难的是把它变成一个真正敢上生产环境的服务。你在 demo 里可以只写一个 Controller 然后直接调上游,但你要面向真实用户,就必须考虑状态、超时、失败重试、限流、配额、存储、监控这一整套东西。我这套方案不是唯一答案,但它是我踩过一轮坑之后验证过的可行路径,如果你按照文章的思路从管道骨架搭起,再根据业务调整防刷参数,至少能少走一半弯路。