百里登风

导航

通过引入大模型来处理图片文件

功能需要,通过编写java代码,引入大模型,对图片文件就系识别,证明图片是否合规,这里只是把功能实现了

这里用的是rouyi的框架来写,核心代码如下:

controller层:

package com.ruoyi.web.controller.ai;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.utils.langchain4j.LangChain4jUtils;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiParam;
import org.springframework.web.multipart.MultipartFile;

import java.util.Map;

/**
 * AI 聊天控制器
 * 提供简单的 AI 问答功能
 *
 * @author sjl
 */
@Api(tags = "AI聊天接口")
@RestController
@RequestMapping("/datalabel/ai")
public class AiChatController extends BaseController
{
    @Autowired
    private LangChain4jUtils langChain4jUtils;


    /**
     * 图片分析接口(使用视觉模型)
     *
     * @param file 上传的图片文件
     * @param question 用户问题(可选,默认为"请分析这张图片中的数据合规性")
     * @return AI 分析结果
     */
    @ApiOperation("图片合规性分析")
    @PostMapping("/analyzeImage")
    public AjaxResult analyzeImage(
            @ApiParam("图片文件") @RequestParam("file") MultipartFile file,
            @ApiParam("用户问题(可选)") @RequestParam(value = "question", required = false, defaultValue = "请分析这张图片中的数据合规性") String question)
    {
        java.io.File tempFile = null;
        try
        {
            // 检查视觉模型是否已配置
            if (!langChain4jUtils.isVisionModelConfigured())
            {
                return AjaxResult.error("视觉模型未配置,请检查 application.yml 中的 vision-model-name 配置");
            }

            // 验证文件
            if (file.isEmpty())
            {
                return AjaxResult.error("图片文件不能为空");
            }

            // 验证文件类型
            String contentType = file.getContentType();
            if (contentType == null || !contentType.startsWith("image/"))
            {
                return AjaxResult.error("只支持图片文件(jpg、png、gif等)");
            }

            // 1. 创建临时文件
            String originalFilename = file.getOriginalFilename();
            String suffix = originalFilename != null ? originalFilename.substring(originalFilename.lastIndexOf(".")) : ".jpg";
            tempFile = java.io.File.createTempFile("upload_", suffix);

            // 2. 将 MultipartFile 写入临时文件
            file.transferTo(tempFile);

            // 3. 调用视觉模型分析图片(传入文件绝对路径)
            String result = langChain4jUtils.analyzeImage(question, tempFile.getAbsolutePath());

            return AjaxResult.success("图片分析成功", result);
        }
        catch (Exception e)
        {
            logger.error("图片分析失败", e);
            return AjaxResult.error("图片分析失败:" + e.getMessage());
        }
        finally
        {
            // 4. 删除临时文件
            if (tempFile != null && tempFile.exists())
            {
                tempFile.delete();
            }
        }
    }

}

 

 

LangChain4jUtils类:
package com.ruoyi.common.utils.langchain4j;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.langchain4j.data.message.AiMessage;
import dev.langchain4j.data.message.SystemMessage;
import dev.langchain4j.data.message.UserMessage;
import dev.langchain4j.model.chat.ChatModel;
import dev.langchain4j.model.embedding.EmbeddingModel;
import dev.langchain4j.service.AiServices;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Component;

import java.util.Iterator;

/**
 * LangChain4j 工具类
 * 提供便捷的 AI 服务调用方法
 *
 * @author ruoyi
 */
@Component
public class LangChain4jUtils
{
    @Autowired(required = false)
    @Qualifier("textChatModel")
    private ChatModel chatModel;

    @Autowired(required = false)
    @Qualifier("visionChatModel")
    private ChatModel visionChatModel;

    @Autowired(required = false)
    private EmbeddingModel embeddingModel;

    protected final Logger logger = LoggerFactory.getLogger(this.getClass());
    private final ObjectMapper mapper = new ObjectMapper();



    /**
     * 分析图片内容(GPU 环境下使用 )
     */
    public String analyzeImage(String userQuestion, String imagePath)
    {
        if (visionChatModel == null)
        {
            return "视觉模型未配置";
        }
        // 构建专业的合规审查系统提示词
        //    要求模型:
        //    - 识别图片中的文字、表格、图表
        //    - 依据三法一条例进行合规性审查
        //    - 只输出 JSON,禁止英文,必须使用中文
        //    - 如果无文字,则在结论中写明
        //    - 输出格式为 {"is_compliant":"合规/不合规","conclusion":"详细分析"}
        // 专业的合规审查提示词
        String systemPrompt = "你是一名资深的数据安全合规专家。请仔细识别图片中的所有文字、表格及图表信息。\n\n" +
                "依据《中华人民共和国网络安全法》、《数据安全法》、《个人信息保护法》及《关键信息基础设施安全保护条例》,对图片内容进行严格的合规性审查。\n\n" +
                "审查重点:\n" +
                "1. 是否存在违规收集个人信息?\n" +
                "2. 数据出境表述是否合规?\n" +
                "3. 是否有未授权的数据共享或交易?\n\n" +
                "输出要求(严格遵循):\n" +
                "1. 仅输出一个标准的 JSON 对象,不要包含 Markdown 格式或其他任何额外文字。\n" +
                "2. **所有文字必须使用中文,禁止出现任何英文单词、字母或标点。**\n" +
                "3. 如果图片中不包含任何文字信息,则在结论中明确说明“图片中未识别到文字”。\n" +
                "JSON 格式:\n" +
                "{\"is_compliant\": \"合规/不合规\", \"conclusion\": \"详细的法律依据和违规点分析\"}";

        try {
            //根据图片路径生成 file:// URI
            java.io.File file = new java.io.File(imagePath);
            String fileUri = file.toURI().toString();

            //  调用视觉模型进行多模态对话
            //  参数:系统消息(合规专家角色)、用户消息(图片 + 用户问题)
            AiMessage aiMessage = visionChatModel.chat(
                    SystemMessage.systemMessage(systemPrompt),
                    UserMessage.from(
                            dev.langchain4j.data.message.ImageContent.from(fileUri), // 图片内容
                            dev.langchain4j.data.message.TextContent.from(userQuestion)  //用户问题
                    )
            ).aiMessage();
            // 获取模型返回的原始文本(预期是一个 JSON 字符串)
            String raw = aiMessage.text();
            logger.info("原始返回结果: {}", raw);
            // 目标:仅删除 JSON 各字段值中的英文字母,保留键名(is_compliant、conclusion)和中文字符
            try {
                // 使用 Jackson 将原始 JSON 字符串解析为树模型
                JsonNode rootNode = mapper.readTree(raw);
                //递归遍历 JSON 树,清理所有文本节点中的英文字母
                cleanJsonValues(rootNode);
                //将清理后的树重新序列化为字符串
                String cleaned = mapper.writeValueAsString(rootNode);
                logger.info("清洗后结果: {}", cleaned);
                return cleaned;
            } catch (Exception e) {
                logger.warn("JSON 解析失败,返回原始结果: {}", raw);
                return raw;
            }
        } catch (Exception e) {
            logger.error("图片分析异常", e);
            return "图片分析失败: " + e.getMessage();
        }
    }

    // 辅助方法:递归清理所有文本节点的英文
    private void cleanJsonValues(JsonNode node) {
        // 处理 JSON 对象:遍历所有字段,对每个字段的值递归清理
        if (node.isObject()) {
            // 获取所有字段名的迭代器
            Iterator<String> fieldNames = node.fieldNames();
            while (fieldNames.hasNext()) {
                String fieldName = fieldNames.next(); // 当前字段名(如 "conclusion")
                JsonNode child = node.get(fieldName); // 当前字段对应的值节点
                if (child.isTextual()) {
                    // 如果值是文本类型,则去掉所有英文字母(保留中文、数字、标点等)
                    String cleanedText = child.asText().replaceAll("[a-zA-Z]+", "");
                    // 将清理后的文本写回原节点
                    ((com.fasterxml.jackson.databind.node.ObjectNode) node).put(fieldName, cleanedText);
                } else {
                    // 如果值不是文本(例如嵌套对象、数组),则递归处理
                    cleanJsonValues(child);
                }
            }
        } else if (node.isArray()) {
            for (JsonNode item : node) {
                cleanJsonValues(item);
            }
        } // 其他类型(数字、布尔)不做处理
    }

}

 

 

LangChain4jConfig工具类

package com.ruoyi.framework.config;

import dev.langchain4j.model.chat.ChatModel;
import dev.langchain4j.model.chat.request.ResponseFormat;
import dev.langchain4j.model.ollama.OllamaChatModel;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.time.Duration;

/**
 * LangChain4j 配置类
 * 配置 Ollama 本地大模型服务
 * 由于 Spring Boot 2.x 不支持 langchain4j-spring-boot-starter,
 * 因此需要手动配置 Bean
 *
 * @author ruoyi
 */
@Configuration
public class LangChain4jConfig
{
    @Value("${langchain4j.ollama.base-url:http://localhost:11434}")
    private String ollamaBaseUrl;

    @Value("${langchain4j.ollama.model-name:qwen3:0.6b}")
    private String ollamaModelName;

    @Value("${langchain4j.ollama.vision-model-name:qwen2.5vl:7b}")
    private String ollamaVisionModelName;

    @Value("${langchain4j.ollama.temperature:0.7}")
    private Double temperature;

    @Value("${langchain4j.ollama.timeout:60}")
    private Integer timeout;

    /**
     * 配置 Ollama Chat Model
     * 使用 qwen3:0.6B 模型
     */
    @Bean("textChatModel")
    public ChatModel chatLanguageModel()
    {
        return OllamaChatModel.builder()
                .baseUrl(ollamaBaseUrl)
                .modelName(ollamaModelName)
                .temperature(temperature)
                .timeout(Duration.ofSeconds(timeout))
                .logRequests(true)
                .logResponses(true)
                .responseFormat(ResponseFormat.JSON)
                .build();
    }

    /**
     * 配置视觉 Chat Model(用于图片识别)
     */
    @Bean("visionChatModel")
    public ChatModel visionChatLanguageModel()
    {
        return OllamaChatModel.builder()
                .baseUrl(ollamaBaseUrl)
                .modelName(ollamaVisionModelName)
                .temperature(0.0)
                .timeout(Duration.ofSeconds(timeout))
                .logRequests(true)
                .logResponses(true)
                .responseFormat(ResponseFormat.JSON)
                .topK(1)                         // 新增:只选最高概率 token
                .seed(42)                        // 新增:固定随机种子
                .build();
    }
}

 

 

配置文件需要把相应的大模型配置上:

# LangChain4j 配置 - Ollama
langchain4j:
  ollama:
    # Ollama 服务地址(默认本地 11434 端口)
    base-url: http://172.16.120.3:11434
    # 模型名称(使用 qwen3:0.6B 模型)
    model-name: qwen3:0.6b
    # 视觉模型名称(用于图片识别)
    vision-model-name: llava:7b
    # 温度参数(0.0-2.0,控制输出的随机性)
    temperature: 0.1
    # 超时时间(秒)
    timeout: 12000

 

 

这次任务,主要是在服务器搭建适配的大模型上比较多问题,代码方面其实问题不大:

一、背景与初始配置

  • 硬件:离线服务器,6 张 NVIDIA Tesla P100(Pascal 架构,计算能力 6.0),驱动版本 575.57.08,CUDA 12.9。
  • 软件:Docker 运行 Ollama 0.14.0,部署了 llava:7bbakllava:7bqwen2.5vl:7b 等多模态模型。
  • 任务:通过 Java(LangChain4j)调用多模态模型识别图片中的文字,依据「三法一条例」进行数据合规审查,输出 JSON 格式的合规结论(纯中文)。

二、第一阶段:模型崩溃(panic/NaN)

问题现象

  • qwen2.5vl:7b → panic: failed to sample token: logits sum to NaN
  • llava:7bbakllava:7b → model runner has unexpectedly stopped(后期日志显示 EOF,无 panic)

尝试与结果

操作结果
关闭 Flash Attention,限制 num_parallel=1 失败
禁用 mmap 失败
增加 shm_size 和 mem_limit 失败
恢复 CUDA_VISIBLE_DEVICES=0 失败
使用纯文本模型 qwen3:0.6b ✅ 成功(证明 Ollama + CUDA 基础可用)
 

结论:问题不在于 Ollama 配置,而是 Pascal 架构的 CUDA kernel 对多模态模型的支持存在系统性 bug。Qwen2.5VL、llava、bakllava 均崩溃,唯独纯文本模型一切正常。

 

三、第二阶段:降低 Ollama 版本尝试

操作

  • 降级到 0.9.2、0.7.0
  • 模型切换为 llava:7bbakllava:7b

结果

  • 仍然崩溃(qwen2.5vl 在 0.9.2 上也是 NaN panic)
  • 0.9.2 甚至无法加载多模态模型(子模块不完整)

结论:降低版本解决不了问题,因为底层 llama.cpp 对 Pascal 的兼容缺陷在所有版本中都存在。

 

四、第三阶段:自编译针对 sm_60 的 CUDA 库

这个阶段需要编译,因为我们通常使用的服务器都是比较老旧的系统,这个时候,我们可以通过docker容器进行编译,这样就能避免老系统的各种依赖环境的困扰了

克隆 Ollama v0.14.0
git clone --branch v0.14.0 --depth 1 https://github.com/ollama/ollama.git
cd ollama

 

获取 llama.cpp 的精确 Commit

Ollama v0.14.0 的 llama/ 子模块(当时是目录而非子模块)对应 llama.cpp 的某一次提交。我们通过 Ollama 仓库中的 .gitmodules 和 Git 读取锁定 hash。

若当前目录下已有 llama/ 子目录(Ollama 自带占位),但为了编译干净,我们不使用它。改用从 upstream 直接克隆。精确 commit 可以通过以下命令从 Ollama 仓库获取:

# 方法1(如果子模块初始化过)
git submodule status llama
# 方法2(直接读取索引)
git ls-tree HEAD llama

 

 

Docker 编译环境

docker pull nvidia/cuda:12.4.1-devel-ubuntu22.04

 

docker run --rm -v /opt/softwares/ollama/ollama:/workspace \
  nvidia/cuda:12.4.1-devel-ubuntu22.04 \
  bash -c '
    set -e
    
    apt-get update && apt-get install -y cmake git
    
    cd /workspace/llama
    
    # 应用补丁(如果 patches 目录存在)
    if [ -d patches ]; then
        for patch in patches/*.patch; do
            echo "Applying $patch"
            git apply "$patch" 2>/dev/null || true
        done
    fi
    
    # 编译
    mkdir -p build && cd build
    cmake .. \
        -DGGML_CUDA=ON \
        -DCMAKE_CUDA_ARCHITECTURES="60" \
        -DGGML_CUDA_FA_TYPES=OFF \
        -DBUILD_SHARED_LIBS=ON \
        -DGGML_BUILD_TESTS=OFF \
        -DGGML_BUILD_EXAMPLES=OFF \
        -DCMAKE_BUILD_TYPE=Release
    make -j$(nproc) ggml-cuda
    echo "Build completed!"
  '

 

 

编译完成后,把/opt/softwares/ollama/ollama/llama/build/bin目录下的libggml-cuda.so文件传输到GPU服务器上

libggml-cuda.so文件在具体哪个目录在编译完成后的日志最后能看到

# 备份原始文件
docker cp ollama:/usr/lib/ollama/cuda_v12/libggml-cuda.so /tmp/libggml-cuda.so.bak
# 替换为新文件(注意:这里要使用从联网机器传过来的真实文件路径)
docker cp /path/to/libggml-cuda.so ollama:/usr/lib/ollama/cuda_v12/libggml-cuda.so
# 设置权限
docker exec -it ollama chmod 644 /usr/lib/ollama/cuda_v12/libggml-cuda.so
# 重启 Ollama
docker-compose restart

 

 

我在GPU服务器上面的docker-compose文件如下

services:
  ollama:
    image: ollama/ollama:0.14.0-vulkan
    restart: always
    container_name: ollama
    entrypoint: ["/usr/bin/ollama"]          # 覆盖为原始 entrypoint
    command: ["serve"]                       # 启动 Ollama 服务
    mem_limit: 64g
    shm_size: 16g

    # GPU 支持配置
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              device_ids: ["0"]
              capabilities: [gpu]

    # 解决 pthread_create failed 错误
    security_opt:
      - seccomp:unconfined

    volumes:
      - ./data:/root/.ollama  # 模型数据持久化

    environment:
      - OLLAMA_HOST=0.0.0.0
      - OLLAMA_KEEP_ALIVE=5m
      - OLLAMA_MAX_LOADED_MODELS=1
      - OLLAMA_VULKAN=1          # 启用Vulkan
      - OLLAMA_FLASH_ATTENTION=false
      - OLLAMA_NUM_PARALLEL=1
      - OLLAMA_MMAP=1
      - LANG=C.UTF-8
      - LC_ALL=C.UTF-8

    ports:
      - "11434:11434"

    # 确保容器有足够的系统资源
    ulimits:
      nproc: 65535
      nofile:
        soft: 65535
        hard: 65535

 

 

经过这个阶段,调用我sping boot的接口,得出的结论

结果

  • ❌ 模型不再崩溃(logits 正常,推理完成)
  • ❌ 输出中英文混杂(如 “wearing dragon costumes” 出现在中文结论中)
  • 模型能运行,但语言模型输出不稳定,倾向于使用训练集中的英文

结论:编译修复了崩溃,但无法解决 Pascal 架构上多模态模型固有的数值精度问题(输出语言混杂)。

 

五、第四阶段:尝试 Vulkan 后端

绕过 CUDA kernel 缺陷,使用 Vulkan 后端,可能获得更稳定的输出。

操作

  • 在容器内安装 libvulkan1 mesa-vulkan-drivers
  • 将容器提交为新镜像(ollama/ollama:0.14.0-vulkan
  • 设置 OLLAMA_VULKAN=1,启动新容器

我这里是通过把离线服务器的容器打包成镜像,传输到虚拟机去下载的

# 加载镜像
docker load -i ollama-base.tar
# 启动一个临时容器,安装 Vulkan 库
docker run --rm -it --name ollama-temp --entrypoint bash ollama-base
# 进入容器后执行:
apt-get update
apt-get install -y libvulkan1 mesa-vulkan-drivers
# 安装完成后,不要退出,打开另一个终端
# 在另一个终端中,将当前运行的容器提交为新镜像
docker commit ollama-temp ollama-vulkan-ready
# 然后退出临时容器
exit

 

 

重新在GPU服务器运行新的容器后,效果也不理想

image

 

宿主机上确实没有 nvidia_icd.json,只有 libnvidia-glvkspirv.so,这说明系统的 NVIDIA 驱动未正确安装 Vulkan ICD 组件。因此,Vulkan 后端在容器内无法与 Tesla P100 通信,Ollama 的 Vulkan 支持会初始化失败。

结论: 在当前的机器上,无论 CUDA 还是 Vulkan,都无法让多模态模型稳定输出纯净中文。这是 Pascal 架构(P100)的硬件限制,而非配置不当。

 

六、最终判断:Pascal 架构的硬件限制

经过所有尝试,得出结论:

  • Pascal 架构(P100)的多模态 CUDA kernel 存在缺陷,无法通过软件配置或自编译解决。
  • 所有多模态模型在 P100 上都会出现输出不稳定、中英文混杂的情况
  • 继续折腾 CUDA/Vulkan 不会有本质改进

 

七、最终解决方案:后处理清洗模型输出

不再花费精力去改变模型行为,而是对模型输出的 JSON 结果进行清洗,只保留中文字符,删除所有英文字母。

private void cleanJsonValues(JsonNode node) {
    if (node.isObject()) {
        Iterator<String> fieldNames = node.fieldNames();
        while (fieldNames.hasNext()) {
            String fieldName = fieldNames.next();
            JsonNode child = node.get(fieldName);
            if (child.isTextual()) {
                // 仅删除文本值中的英文字母,保留键名
                String cleanedText = child.asText().replaceAll("[a-zA-Z]+", "");
                ((ObjectNode) node).put(fieldName, cleanedText);
            } else {
                cleanJsonValues(child);
            }
        }
    } else if (node.isArray()) {
        for (JsonNode item : node) {
            cleanJsonValues(item);
        }
    }
}

效果

  • 键名 is_compliantconclusion 完整保留
  • 所有英文词汇(如 “wearing dragon costumes”)被删除
  • 中文分析结论保持完整
  • JSON 结构完整无损

当前运行状态

  • 模型使用 CPU 推理(视觉编码器 + 语言模型均在 CPU)
  • 速度较慢(单张图片约 10-20 秒),但对于合规审查任务可接受
  • 输出为纯净中文 JSON

 

八、技术栈总结

组件最终决策原因
Ollama 版本 0.14.0(CUDA + CPU 混合) 0.9.2 等版本同样不行
多模态模型 llava:7b(Q4_K_M) 能工作,输出结构完整
后端 CPU 推理(CUDA 编译后仍不稳定) Pascal 硬件限制
后处理 Jackson 解析 JSON,删除英文 成本最低,立即可用
未来提升方向 更换 GPU(V100 或更高)或采用 OCR + 纯文本模型 突破硬件限制

 

 

posted on 2026-06-20 18:21  百里登峰  阅读(0)  评论(0)    收藏  举报