一、背景介绍
我最近在做一个小红书图文自动生成的后端系统:用户输入一个主题(比如"周末露营清单"),系统自动产出标题、大纲、正文,连配图都帮你配好。
但 "配图" 这个环节,比想象中棘手得多。一篇露营笔记里,既有要写实感的场景照(帐篷、篝火、星空),又可能有想要轻松搞笑的段落,配个表情包更出彩。如果所有图片都用同一个来源,要么图不对文,要么风格统一到发闷。
最直接的思路是写死规则:关键词里出现"表情"就走表情包,"产品"就走图库。可你很快会发现,这种 if-else 规则永远列举不完,文章内容千变万化,规则只会越写越脏,最后变成一个维护噩梦。
所以我把这个问题换了个角度想:"判断该用什么图"这件事,为什么不让大模型来做? 它读懂文章、判断语义,本来就是在行的。于是就有了这套设计------大模型负责"聪明地选",策略模式负责"正确地执行"。这篇文章就来讲讲它是怎么落地的。
二、方案设计

① 智能体4:语义分析 + 选择策略
ArticleAgentService.agent4AnalyzeImageRequirements() 会把文章正文和一个"可用配图方式说明"一起扔给大模型:
- buildAvailableMethodsDescription()把 PEXELS / ICONIFY / EMOJI_PACK
三种方式各自的适用场景描述出来(真实场景→PEXELS、图标→ICONIFY、表情包→EMOJI_PACK)。 - PromptConstant.AGENT4_IMAGE_REQUIREMENTS_PROMPT指示大模型"为每个配图位置匹配最合适的 imageSource",并返回一个 JSON,每个配图需求都带 imageSource 字段。
也就是说:"这篇讲美妆的笔记,配图应该用 PEXELS 找真实产品图;这句吐槽适合用 EMOJI_PACK 表情包"------这是大模型读懂内容后做的决定,然后存进 ArticleState.ImageRequirement.imageSource。
② 智能体5:按策略取图
agent5GenerateImages()(ArticleAgentService.java:161)遍历配图需求,调用 imageServiceStrategy.getImageAndUpload(imageSource, request)。
③ 策略注册表 + 分发
ImageServiceStrategy 是典型的策略模式:
- @PostConstruct 把 Spring 自动注入的所有 ImageSearchService 实现按 ImageMethodEnum 注册进 EnumMap。
- resolveMethod() 把字符串 imageSource 通过 ImageMethodEnum.getByValue() 转成枚举,再在 map 里查到对应的具体服务(Pexels / Iconify / EmojiPack 各自的实现类)。
- 遇到未知来源默认回退到 PEXELS;服务不可用或取图失败则走 PICSUM 降级并统一上传 COS。
三、代码
java
/**
* 智能体4:分析配图需求(在正文中插入占位符)
*/
private void agent4AnalyzeImageRequirements(ArticleState state) {
// 构建可用配图方式说明
String availableMethods = buildAvailableMethodsDescription(state.getEnabledImageMethods());
String prompt = PromptConstant.AGENT4_IMAGE_REQUIREMENTS_PROMPT
.replace("{mainTitle}", state.getTitle().getMainTitle())
.replace("{content}", state.getContent())
.replace("{availableMethods}", availableMethods);
String content = callLlm(prompt);
ArticleState.Agent4Result agent4Result = parseJsonResponse(
content,
ArticleState.Agent4Result.class,
"配图需求"
);
// 更新正文为包含占位符的版本
state.setContent(agent4Result.getContentWithPlaceholders());
state.setImageRequirements(agent4Result.getImageRequirements());
log.info("智能体4:配图需求分析成功, count={}, 已在正文中插入占位符",
agent4Result.getImageRequirements().size());
}
/**
* 智能体4:分析配图需求(支持多种图片来源,使用占位符方案)
*/
String AGENT4_IMAGE_REQUIREMENTS_PROMPT = """
你是一位小红书资深视觉编辑,擅长为爆款笔记搭配高颜值配图,让图文完美融合。
根据以下文章内容,分析配图需求,并在正文中插入图片占位符:
主标题:{mainTitle}
正文:
{content}
可用的配图方式:
{availableMethods}
要求:
1. 识别需要配图的位置(封面、关键章节、段落之间等),根据内容灵活决定数量,避免过多或过少
2. 在正文中插入占位符:
- 普通图片:{{IMAGE_PLACEHOLDER_N}}(N 为序号,独占一行)
- 小图标:{{ICON_PLACEHOLDER_N}}(可放在文字行内任意位置,仅用于 ICONIFY)
- 封面图(position=1)不插入正文,不需要占位符
3. 只能从上述可用方式中选择,为每个配图匹配最合适的 imageSource:
- PEXELS:真实场景、产品照片、人物、风景等写实图片
- ICONIFY:图标、符号、小型装饰(如箭头、勾选、星星等)
- EMOJI_PACK:表情包、搞笑图片、轻松幽默配图
4. 参数填写规则:
- PEXELS:keywords 填英文搜索词,准确具体;prompt 留空
- ICONIFY:keywords 填英文图标词(如 check、arrow、star);prompt 留空
- EMOJI_PACK:keywords 填中文或英文表情词(如开心、哭笑、无语);prompt 留空;系统会自动追加"表情包"搜索
5. position=1 固定为封面图;placeholderId 必须与正文中的占位符完全一致
请直接返回 JSON 格式,不要有其他内容:
{
"contentWithPlaceholders": "## 章节标题\\n\\n正文内容...\\n\\n{{IMAGE_PLACEHOLDER_1}}\\n\\n继续正文...",
"imageRequirements": [
{
"position": 1,
"type": "cover",
"sectionTitle": "",
"imageSource": "EMOJI_PACK",
"keywords": "开心大笑",
"prompt": "",
"placeholderId": ""
},
{
"position": 2,
"type": "section",
"sectionTitle": "章节标题",
"imageSource": "PEXELS",
"keywords": "英文关键词",
"prompt": "",
"placeholderId": "{{IMAGE_PLACEHOLDER_1}}"
}
]
}
""";
/**
* 构建可用配图方式说明
*/
private String buildAvailableMethodsDescription(List<String> enabledMethods) {
// 如果为空或 null,表示支持所有方式
if (enabledMethods == null || enabledMethods.isEmpty()) {
return getAllMethodsDescription();
}
// 只描述允许的方式
StringBuilder sb = new StringBuilder();
for (String method : enabledMethods) {
ImageMethodEnum methodEnum = ImageMethodEnum.getByValue(method);
if (methodEnum != null && !methodEnum.isFallback()) {
sb.append(" - ").append(methodEnum.getValue())
.append(": ").append(getMethodUsageDescription(methodEnum))
.append("\n");
}
}
return sb.toString();
}
/**
* 获取所有配图方式的完整描述
*/
private String getAllMethodsDescription() {
return """
- PEXELS: 适合真实场景、产品照片、人物照片、自然风景等写实图片
- ICONIFY: 适合图标、符号、小型装饰性图标(如:箭头、勾选、星星、心形等)
- EMOJI_PACK: 适合表情包、搞笑图片、轻松幽默的配图
""";
}
/**
* 获取配图方式的使用说明
*/
private String getMethodUsageDescription(ImageMethodEnum method) {
return switch (method) {
case PEXELS -> "适合真实场景、产品照片、人物照片、自然风景等写实图片";
case ICONIFY -> "适合图标、符号、小型装饰性图标(如:箭头、勾选、星星、心形等)";
case EMOJI_PACK -> "适合表情包、搞笑图片、轻松幽默的配图";
default -> method.getDescription();
};
}
策略注册表 + 分发
java
/**
* 图片服务策略选择器
* 根据图片来源类型选择对应的图片服务实现
*
* 设计说明:
* - 自动注册所有 ImageSearchService 实现
* - 根据 ImageMethodEnum 的元数据自动选择正确的参数
* - 支持服务可用性检查和自动降级
* - 统一处理图片上传到 COS
*
*/
@Service
@Slf4j
public class ImageServiceStrategy {
@Resource
private List<ImageSearchService> imageSearchServices;
@Resource
private CosService cosService;
/**
* 图片服务映射:ImageMethodEnum -> ImageSearchService
*/
private final Map<ImageMethodEnum, ImageSearchService> serviceMap = new EnumMap<>(ImageMethodEnum.class);
@PostConstruct
public void init() {
// 将所有 ImageSearchService 实现注册到映射表
for (ImageSearchService service : imageSearchServices) {
ImageMethodEnum method = service.getMethod();
serviceMap.put(method, service);
log.info("注册图片服务: {} -> {} (AI生图: {}, 降级: {})",
method.getValue(),
service.getClass().getSimpleName(),
method.isAiGenerated(),
method.isFallback());
}
}
/**
* 获取图片并上传到 COS(推荐方法)
* 统一处理所有图片来源的上传逻辑
*
* @param imageSource 图片来源
* @param request 图片请求对象
* @return 图片获取结果(包含 COS URL)
*/
public ImageResult getImageAndUpload(String imageSource, ImageRequest request) {
ImageMethodEnum method = resolveMethod(imageSource);
ImageSearchService service = serviceMap.get(method);
if (service == null || !service.isAvailable()) {
log.warn("图片服务不可用: {}, 尝试降级", method);
return handleFallbackWithUpload(request.getPosition());
}
try {
// 1. 获取图片数据
ImageData imageData = service.getImageData(request);
if (imageData == null || !imageData.isValid()) {
log.warn("图片数据获取失败, 使用降级方案, method={}", method);
return handleFallbackWithUpload(request.getPosition());
}
// 2. 上传到 COS
String folder = getFolderForMethod(method);
String cosUrl = cosService.uploadImageData(imageData, folder);
if (cosUrl != null && !cosUrl.isEmpty()) {
log.info("图片获取并上传成功, method={}, cosUrl={}", method, cosUrl);
return new ImageResult(cosUrl, method);
} else {
log.warn("图片上传 COS 失败, 使用降级方案, method={}", method);
return handleFallbackWithUpload(request.getPosition());
}
} catch (Exception e) {
log.error("获取图片并上传异常, method={}", method, e);
return handleFallbackWithUpload(request.getPosition());
}
}
/**
* 根据图片方法获取 COS 文件夹
*/
private String getFolderForMethod(ImageMethodEnum method) {
return switch (method) {
case PEXELS -> "pexels";
case ICONIFY -> "iconify";
case EMOJI_PACK -> "emoji-pack";
case PICSUM -> "picsum";
};
}
/**
* 解析图片来源,处理未知值
*/
private ImageMethodEnum resolveMethod(String imageSource) {
// 第1步:尝试根据字符串值找到对应的枚举
ImageMethodEnum method = ImageMethodEnum.getByValue(imageSource);
// 第2步:如果没找到(用户传了个不认识的字符串),用默认值兜底
if (method == null) {
log.warn("未知的图片来源: {}, 默认使用 {}", imageSource, ImageMethodEnum.getDefaultSearchMethod());
return ImageMethodEnum.getDefaultSearchMethod();
}
return method;
}
/**
* 处理降级逻辑(含上传)
*/
private ImageResult handleFallbackWithUpload(Integer position) {
int pos = position != null ? position : 1;
String fallbackUrl = getFallbackImage(pos);
// 将降级图片也上传到 COS
ImageData fallbackData = ImageData.fromUrl(fallbackUrl);
String cosUrl = cosService.uploadImageData(fallbackData, "fallback");
// 如果上传失败,直接使用原始 URL
String finalUrl = (cosUrl != null && !cosUrl.isEmpty()) ? cosUrl : fallbackUrl;
return new ImageResult(finalUrl, ImageMethodEnum.getFallbackMethod());
}
/**
* 获取指定方法的图片服务
*/
public ImageSearchService getService(ImageMethodEnum method) {
return serviceMap.get(method);
}
/**
* 获取降级图片
*/
public String getFallbackImage(int position) {
ImageSearchService defaultService = serviceMap.get(ImageMethodEnum.getDefaultSearchMethod());
if (defaultService != null) {
return defaultService.getFallbackImage(position);
}
return String.format("https://picsum.photos/800/600?random=%d", position);
}
/**
* 获取所有已注册的图片服务类型
*/
public List<ImageMethodEnum> getRegisteredMethods() {
return List.copyOf(serviceMap.keySet());
}
/**
* 图片获取结果
*/
public static class ImageResult {
private final String url;
private final ImageMethodEnum method;
public ImageResult(String url, ImageMethodEnum method) {
this.url = url;
this.method = method;
}
public String getUrl() {
return url;
}
public ImageMethodEnum getMethod() {
return method;
}
public boolean isSuccess() {
return url != null && !url.isEmpty();
}
}
}
抽象接口
java
/**
* 图片检索服务接口
* 抽象图片检索逻辑,便于扩展多种图片来源
*
*/
public interface ImageSearchService {
/**
* 根据请求获取图片(推荐使用此方法)
*
* @param request 图片请求对象,包含 keywords、prompt 等参数
* @return 图片 URL,获取失败返回 null
*/
default String getImage(ImageRequest request) {
// 步骤1:标准化参数(模板定好的)
String param = request.getEffectiveParam(getMethod().isAiGenerated());
// 步骤2:执行搜索(子类实现)
return searchImage(param);
}
/**
* 获取图片数据(用于统一上传到 COS)
* 子类可重写此方法返回更高效的数据格式(如字节数据)
*
* @param request 图片请求对象
* @return ImageData 对象,包含图片字节或 URL
*/
default ImageData getImageData(ImageRequest request) {
// 步骤1:先拿URL(可以走上面的模板方法)
String url = getImage(request);
// 步骤2:包装成统一格式(模板定好的)
return ImageData.fromUrl(url);
}
/**
* 根据关键词/提示词获取图片
*
* @param keywords 搜索关键词(图库检索)或生图提示词(AI 生图)
* @return 图片 URL,获取失败返回 null
*/
String searchImage(String keywords);
/**
* 获取图片服务类型
*
* @return 图片服务类型枚举
*/
ImageMethodEnum getMethod();
/**
* 获取降级图片 URL
*
* @param position 位置序号(用于生成唯一的随机图片)
* @return 降级图片 URL
*/
String getFallbackImage(int position);
/**
* 判断服务是否可用
* 子类可重写此方法进行健康检查
*
* @return 服务是否可用
*/
default boolean isAvailable() {
return true;
}
Pexels 具体实现策略
java
/**
* Pexels 图片检索服务
*
*/
@Service
@Slf4j
public class PexelsService implements ImageSearchService {
@Resource
private PexelsConfig pexelsConfig;
private final OkHttpClient httpClient = new OkHttpClient();
@Override
public String searchImage(String keywords) {
try {
String url = buildSearchUrl(keywords);
Request request = new Request.Builder()
.url(url)
.addHeader("Authorization", pexelsConfig.getApiKey())
.build();
try (Response response = httpClient.newCall(request).execute()) {
if (!response.isSuccessful()) {
log.error("Pexels API 调用失败: {}", response.code());
return null;
}
String responseBody = response.body().string();
return extractImageUrl(responseBody, keywords);
}
} catch (IOException e) {
log.error("Pexels API 调用异常", e);
return null;
}
}
@Override
public ImageMethodEnum getMethod() {
return ImageMethodEnum.PEXELS;
}
@Override
public String getFallbackImage(int position) {
return String.format(PICSUM_URL_TEMPLATE, position);
}
/**
* 构建搜索 URL
*
* @param keywords 搜索关键词
* @return 完整的搜索 URL
*/
private String buildSearchUrl(String keywords) {
return String.format("%s?query=%s&per_page=%d&orientation=%s",
PEXELS_API_URL,
keywords,
PEXELS_PER_PAGE,
PEXELS_ORIENTATION_LANDSCAPE);
}
/**
* 从响应中提取图片 URL
*
* @param responseBody 响应体
* @param keywords 搜索关键词(用于日志)
* @return 图片 URL,未找到返回 null
*/
private String extractImageUrl(String responseBody, String keywords) {
JsonObject jsonObject = JsonParser.parseString(responseBody).getAsJsonObject();
JsonArray photos = jsonObject.getAsJsonArray("photos");
if (photos.isEmpty()) {
log.warn("Pexels 未检索到图片: {}", keywords);
return null;
}
JsonObject photo = photos.get(0).getAsJsonObject();
JsonObject src = photo.getAsJsonObject("src");
return src.get("large").getAsString();
}
}
Emoj表情包具体实现策略
java
@Service
@Slf4j
public class EmojiPackService implements ImageSearchService {
@Resource
private EmojiPackConfig emojiPackConfig;
@Override
public String searchImage(String keywords) {
if (StrUtil.isBlank(keywords)) {
log.warn("表情包搜索关键词为空");
return null;
}
try {
// 1. 构建搜索词(程序固定拼接"表情包")
String searchText = keywords + emojiPackConfig.getSuffix();
log.info("表情包搜索: {} -> {}", keywords, searchText);
// 2. 构建搜索 URL
String fetchUrl = buildSearchUrl(searchText);
// 3. 使用 Jsoup 获取页面
Document document = Jsoup.connect(fetchUrl)
.timeout(emojiPackConfig.getTimeout())
.userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
.get();
// 4. 定位图片容器
Element div = document.getElementsByClass("dgControl").first();
if (div == null) {
log.warn("Bing 未找到图片容器, keywords={}", keywords);
return null;
}
// 5. 使用 CSS 选择器提取图片
Elements imgElements = div.select("img.mimg");
if (imgElements.isEmpty()) {
log.warn("Bing 未检索到表情包, keywords={}, searchText={}", keywords, searchText);
return null;
}
// 6. 获取第一张图片 URL
String imageUrl = imgElements.get(0).attr("src");
if (StrUtil.isBlank(imageUrl)) {
log.warn("图片 URL 为空, keywords={}", keywords);
return null;
}
// 7. 清理 URL 参数(移除 ?w=xxx&h=xxx)
imageUrl = cleanImageUrl(imageUrl);
log.info("表情包检索成功: {} -> {}", keywords, imageUrl);
return imageUrl;
} catch (Exception e) {
log.error("表情包检索异常, keywords={}", keywords, e);
return null;
}
}
@Override
public ImageMethodEnum getMethod() {
return ImageMethodEnum.EMOJI_PACK;
}
@Override
public String getFallbackImage(int position) {
return String.format(PICSUM_URL_TEMPLATE, position);
}
/**
* 构建 Bing 图片搜索 URL
*/
private String buildSearchUrl(String searchText) {
String encodedText = URLEncoder.encode(searchText, StandardCharsets.UTF_8);
// 必须添加 mmasync=1 参数
return String.format("%s?q=%s&mmasync=1",
emojiPackConfig.getSearchUrl(),
encodedText);
}
/**
* 清理图片 URL 参数
* 移除 ?w=xxx&h=xxx 等参数,避免图片质量下降和访问问题
*/
private String cleanImageUrl(String url) {
if (StrUtil.isBlank(url)) {
return url;
}
int questionMarkIndex = url.indexOf("?");
if (questionMarkIndex > 0) {
return url.substring(0, questionMarkIndex);
}
return url;
}
}
智能体5:按策略取图
java
/**
* 智能体5:生成配图
*/
private void agent5GenerateImages(ArticleState state, Consumer<String> streamHandler) {
List<ArticleState.ImageResult> imageResults = new ArrayList<>();
for (ArticleState.ImageRequirement requirement : state.getImageRequirements()) {
String imageSource = requirement.getImageSource();
log.info("智能体5:开始检索配图, position={}, keywords={}",
requirement.getPosition(), requirement.getKeywords());
// 构建图片请求对象
ImageRequest imageRequest = ImageRequest.builder()
.keywords(requirement.getKeywords())
.prompt(requirement.getPrompt())
.position(requirement.getPosition())
.type(requirement.getType())
.build();
// 使用策略模式获取图片并统一上传到 COS(重点)
ImageServiceStrategy.ImageResult result = imageServiceStrategy.getImageAndUpload(imageSource, imageRequest);
String cosUrl = result.getUrl();
ImageMethodEnum method = result.getMethod();
// 创建配图结果(URL 已经是 COS 地址)
ArticleState.ImageResult imageResult = buildImageResult(requirement, cosUrl, method);
imageResults.add(imageResult);
// 推送单张配图完成
String imageCompleteMessage = SseMessageTypeEnum.IMAGE_COMPLETE.getStreamingPrefix() + GsonUtils.toJson(imageResult);
streamHandler.accept(imageCompleteMessage);
log.info("智能体5:配图获取并上传成功, position={}, method={}, cosUrl={}",
requirement.getPosition(), method.getValue(), cosUrl);
}
state.setImages(imageResults);
log.info("智能体5:所有配图生成并上传完成, count={}", imageResults.size());
}