大模型负责“聪明“,策略模式负责“执行“:智能配图系统如何自动选择图片来源

一、背景介绍

我最近在做一个小红书图文自动生成的后端系统:用户输入一个主题(比如"周末露营清单"),系统自动产出标题、大纲、正文,连配图都帮你配好。

"配图" 这个环节,比想象中棘手得多。一篇露营笔记里,既有要写实感的场景照(帐篷、篝火、星空),又可能有想要轻松搞笑的段落,配个表情包更出彩。如果所有图片都用同一个来源,要么图不对文,要么风格统一到发闷。

最直接的思路是写死规则:关键词里出现"表情"就走表情包,"产品"就走图库。可你很快会发现,这种 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());
    }
相关推荐
阿萨德528号3 天前
npm 包发布实战指南:从零发布、更新迭代到版本治理
前端·npm·策略模式
AI人工智能+电脑小能手4 天前
大白话说Java设计模式-26-策略模式(业务实战篇)
java·spring·设计模式·策略模式·支付系统·算法切换
coding4u4 天前
Mac 多桌面(Spaces)的正确用法:你的屏幕可以大三倍
macos·docker·计算机外设·文件管理·策略模式
AI人工智能+电脑小能手4 天前
大白话说Java设计模式-27-策略模式(源码剖析篇)
java·spring·设计模式·策略模式·源码分析·事务管理·comparator
小此方4 天前
Linux加餐(一):藏在Linux中的设计模式(一)策略模式与日志
linux·设计模式·策略模式
coding4u13 天前
Mac 外接显示器完全指南:从插上线到调好每个参数
macos·计算机外设·文件管理·显示器·策略模式·quick look扩展
geminigoth15 天前
Spring AI Alibaba 入门开发一
大模型开发·ai开发·springai开发·deepseek开发·ollama部署开发
神奇霸王龙15 天前
AgentDesk 配置 Codex+ selltoken 中转 API 实测教程(2026 年 8 月更新)
人工智能·ai·ai编程·策略模式·codex·agentdesk
赶路人儿20 天前
MacOS 下实现 AI 操控电脑(Computer Use)的思考
人工智能·macos·策略模式