CodeBuddy × 硅基流动:从零搭建 PaddleOCR-VL 文档识别应用(上篇)——需求拆解、工程骨架与 SiliconFlow 集成

摘要 :本篇记录我如何用 AI 编程助手 CodeBuddy 结对编程,在半小时内从一句自然语言需求出发,搭出一个可运行的「Spring Boot + Thymeleaf + 硅基流动(SiliconFlow)PaddleOCR-VL」文档 OCR 应用。上篇聚焦工程诞生过程 、SiliconFlow OpenAI 兼容接口的 Java 集成 与核心代码拆解;下篇会讲提示词调优实测、前端体验打磨、乱码等踩坑与生产化改造。


一、为什么是「CodeBuddy + 硅基流动 + PaddleOCR-VL」

这三者的组合,恰好覆盖了「谁来写代码 」「模型从哪来 」「用什么模型」三个问题:

角色 选型 解决什么问题
编码生产力 CodeBuddy(IDE 内的 AI 编程助手) 需求 → 工程骨架 → 多文件改造 → 报错定位 → 重启验证,全流程自然语言驱动
模型服务 硅基流动 SiliconFlow 提供 OpenAI 兼容的多模态推理 API,免去自建 GPU 环境与模型部署运维
OCR 能力 PaddleOCR-VL (PaddlePaddle/PaddleOCR-VL-1.5) 视觉语言模型(VLM)直接吃图,输出 Markdown 结构化文本,版面/表格理解能力强

1.1 传统 OCR 方案 vs 多模态大模型 OCR

维度 传统 OCR(检测+识别流水线) PaddleOCR-VL(VLM)
输出 文本框坐标 + 逐行文本 带层级的 Markdown(标题/段落/表格/列表)
表格还原 需额外表格结构识别模型 原生输出 Markdown 表格
集成成本 部署多阶段模型,链路长 一次 HTTP 请求
语义理解 无 可识别截图、报表、票据的语义分组
可控性 高 依赖提示词,需要调优(见下篇)

对于「上传一张截图/扫描件 → 拿到可复制的结构化文本」这类需求,VLM 方案的代码量可以低到几百行,非常适合作为 AI 编程助手的生成目标------这也是本文选择它的重要原因。

1.2 硅基流动的接口长什么样

硅基流动提供 OpenAI 兼容 的 /chat/completions 接口,OCR 本质上就是一次「图片 + 文本指令」的多模态对话:

http 复制代码
POST https://api.siliconflow.cn/v1/chat/completions
Authorization: Bearer sk-你的Key
Content-Type: application/json
json 复制代码
{
  "model": "PaddlePaddle/PaddleOCR-VL-1.5",
  "messages": [
    {
      "role": "user",
      "content": [
        { "type": "image_url", "image_url": { "url": "data:image/png;base64,iVBORw0..." } },
        { "type": "text", "text": "OCR:" }
      ]
    }
  ],
  "temperature": 0,
  "max_tokens": 2048
}

关键点:content 是一个数组 ,图片与文本是并列的两个 part;图片既可以传公网 URL,也可以传 data:image/{type};base64,... 内联。


二、环境与准备

项 版本/说明
JDK 17+
构建 Maven 3.8+
Spring Boot 3.3.5(spring-boot-starter-web / thymeleaf)
前端 原生 CSS + marked.js(CDN)
SiliconFlow Key 控制台 https://cloud.siliconflow.cn 获取


图 1 SiliconFlow 控制台:创建并复制 API Key(密钥请打码)

获取 Key 后先做一次连通性验证(命令行即可,不需要写 Java):

bash 复制代码
curl https://api.siliconflow.cn/v1/chat/completions ^
  -H "Authorization: Bearer sk-你的Key" ^
  -H "Content-Type: application/json" ^
  -d "{\"model\":\"PaddlePaddle/PaddleOCR-VL-1.5\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"image_url\",\"image_url\":{\"url\":\"https://example.com/doc.png\"}},{\"type\":\"text\",\"text\":\"OCR:\"}]}],\"temperature\":0,\"max_tokens\":2048}"

Windows PowerShell 里建议改用 Invoke-RestMethod,避免引号转义问题;能拿到 choices[0].message.content 就说明网络与 Key 都没问题。


三、用 CodeBuddy 从 0 到 1:真实迭代过程

这一步是本篇的重点。我并没有先写代码,而是先把需求描述清楚交给 CodeBuddy,再逐轮用自然语言驱动它修改。

3.1 第一轮:一句话生成工程骨架

我给 CodeBuddy 的提示词(原文摘录):

text 复制代码
请基于 Spring Boot 3.3 + Java 17 + Thymeleaf 创建一个文档 OCR 演示工程:
1. 调用硅基流动(SiliconFlow)的 PaddleOCR-VL 多模态大模型,
   接口为 OpenAI 兼容的 POST https://api.siliconflow.cn/v1/chat/completions;
2. 支持两种输入:本地上传图片(转 base64 data URL)与填写图片 URL;
3. 页面用 Thymeleaf,识别结果用 Markdown 渲染(引入 marked.js),
   展示原图预览与 Token 消耗;
4. 提供 /ocr 表单提交与 /ocr/ajax 两种接口,前端用 fetch 异步提交并显示加载遮罩;
5. 配置集中在 application.yml,识别提示词可配置,并提供多套预设模板;
6. 包含完整错误处理(401、上游 500)与日志。
请给出完整可运行的工程结构与全部代码。


图 2 在 CodeBuddy 中用一段需求描述生成工程骨架

写提示词的经验 :把「技术栈 / 功能点 / 约束 / 交付形态」四件事一次性说清,CodeBuddy 生成的结构就基本不需要推倒重来。它一次性产出了 pom.xml、启动类、config/、controller/、service/、dto/、templates/index.html、static/css/style.css 与 README.md。

3.2 后续轮次:自然语言驱动的增量迭代

轮次 我给 CodeBuddy 的指令 它的产出
2 「用官方极简提示词 OCR:,并加一个提示词预设下拉和实时字数统计」 新增 ocr.prompt-presets 配置、OcrProperties.PromptPreset、页面下拉 + JS 字数提醒
3 直接把上游报错贴给它:HTTP 500 / code 50507 定位为提示词过长被拒,在 Service 中新增 50507 专项中文错误提示
4 贴一张「百度配额」控制台截图,要求按场景优化提示词 重写提示词与三套预设,并在 README 中沉淀实测结论表
5 「预设模板中文乱码」+「图片预览和结果放右侧,输入放左侧」 将配置迁移到 UTF-8 的 application.yml,页面改为左右分栏布局


图 3 与 CodeBuddy 的多轮迭代:贴报错、发截图、提布局需求

3.3 最终工程结构

复制代码
siliconflow-paddleocr-demo/
├── pom.xml
├── src/main/java/com/example/siliconflowpaddleocr/
│   ├── SiliconflowPaddleocrDemoApplication.java   # 启动类
│   ├── config/
│   │   ├── RestTemplateConfig.java                # RestTemplate(超时配置)
│   │   └── OcrProperties.java                     # ocr.* 配置(默认提示词 + 预设)
│   ├── controller/
│   │   ├── OcrController.java                     # /、/ocr、/ocr/ajax
│   │   └── ImageController.java                   # /api/image/{id} 图片回显
│   ├── service/
│   │   ├── OcrService.java                        # 接口
│   │   ├── SiliconFlowOcrService.java             # SiliconFlow 调用实现
│   │   └── ImageCache.java                        # 内存图片缓存(回显用)
│   └── dto/                                       # 请求/响应/结果对象
└── src/main/resources/
    ├── application.yml                            # 全部配置(UTF-8)
    ├── templates/index.html                       # Thymeleaf 页面
    └── static/css/style.css


图 4 最终工程目录结构


四、核心实现拆解

4.1 配置:一切可配(application.yml)

yaml 复制代码
server:
  port: 8080

spring:
  servlet:
    multipart:
      max-file-size: 15MB
      max-request-size: 15MB
  thymeleaf:
    cache: false

siliconflow:
  api:
    base-url: "https://api.siliconflow.cn/v1"
    model: "PaddlePaddle/PaddleOCR-VL-1.5"
    key: "sk-你的Key"
    max-tokens: 2048

ocr:
  default-prompt: "OCR:"
  # 是否过滤模型输出中的 <|LOC_x|> 布局坐标标记
  strip-loc-tokens: true
  prompt-presets:
    - name: "OCR:(官方推荐,最准)"
      value: "OCR:"
    - name: "表格 / 截图结构化(约 60 字)"
      value: "请把图片中的文字逐行完整识别出来,表格输出为 Markdown 表格,数字与单位原样保留,不要解释。看不清的字符用 □ 代替。"

对应绑定类用 @ConfigurationProperties,预设模板是一个嵌套列表,页面下拉框直接由配置驱动,加模板不用改代码:

java 复制代码
@Component
@ConfigurationProperties(prefix = "ocr")
public class OcrProperties {
    private String defaultPrompt;
    private List<PromptPreset> promptPresets = new ArrayList<>();

    public static class PromptPreset {
        private String name;   // 下拉框展示名
        private String value;  // 实际提交给模型的提示词
        // getter / setter 省略
    }
}

⚠️ 这里有个大坑:这套配置最初写在 application.properties 里,页面上的中文预设全是乱码 。原因与修复方案放在下篇第一节,建议直接一步到位用 application.yml。

4.2 请求体:多模态 content 数组

OpenAI 兼容协议里,content 既可以是字符串,也可以是 part 数组。DTO 这样建模:

java 复制代码
public class ChatCompletionRequest {
    private String model;
    private List<Message> messages;
    private Double temperature;
    private Integer maxTokens;
    private Boolean stream;

    public static class ContentPart {
        private String type;               // "image_url" | "text"
        private Map<String, Object> imageUrl;
        private String text;

        public static ContentPart image(String url) {
            ContentPart p = new ContentPart();
            p.type = "image_url";
            p.imageUrl = Collections.singletonMap("url", url);
            return p;
        }

        public static ContentPart text(String text) {
            ContentPart p = new ContentPart();
            p.type = "text";
            p.text = text;
            return p;
        }
    }
}

4.3 服务层:一次 HTTP 调用完成 OCR

java 复制代码
@Service
public class SiliconFlowOcrService implements OcrService {

    @Value("${siliconflow.api.base-url}")   private String baseUrl;
    @Value("${siliconflow.api.model}")      private String defaultModel;
    @Value("${siliconflow.api.key}")        private String defaultApiKey;
    @Value("${siliconflow.api.max-tokens:4096}") private Integer maxTokens;

    private final RestTemplate restTemplate;
    private final OcrProperties ocrProperties;

    @Override
    public OcrResult recognize(byte[] imageBytes, String mediaType, String prompt,
                               String apiKeyOverride, String modelOverride) {
        String type = (mediaType != null && !mediaType.isBlank()) ? mediaType : "image/png";
        String dataUrl = "data:" + type + ";base64,"
                + Base64.getEncoder().encodeToString(imageBytes);
        return callApi(dataUrl, prompt, apiKeyOverride, modelOverride);
    }

    private OcrResult callApi(String imageUrl, String prompt,
                              String apiKeyOverride, String modelOverride) {
        String apiKey = StringUtils.hasText(apiKeyOverride) ? apiKeyOverride : defaultApiKey;
        String model  = StringUtils.hasText(modelOverride)  ? modelOverride  : defaultModel;

        if (!StringUtils.hasText(apiKey) || "YOUR_API_KEY_HERE".equals(apiKey.trim())) {
            OcrResult r = new OcrResult();
            r.setError("未配置有效的 SiliconFlow API Key...");
            return r;
        }

        String effectivePrompt = StringUtils.hasText(prompt)
                ? prompt : ocrProperties.getDefaultPrompt();

        List<ContentPart> content = Arrays.asList(
                ContentPart.image(imageUrl),
                ContentPart.text(effectivePrompt));
        ChatCompletionRequest request = new ChatCompletionRequest(
                model, Collections.singletonList(new Message("user", content)));
        request.setTemperature(0.0);      // OCR 要确定性,必须 0
        request.setMaxTokens(maxTokens);  // 长文档要调大
        request.setStream(false);

        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_JSON);
        headers.setBearerAuth(apiKey);

        OcrResult result = new OcrResult();
        result.setModel(model);
        try {
            String endpoint = baseUrl.endsWith("/")
                    ? baseUrl + "chat/completions" : baseUrl + "/chat/completions";
            ChatCompletionResponse response = restTemplate.postForObject(
                    endpoint, new HttpEntity<>(request, headers), ChatCompletionResponse.class);

            if (response == null) {
                result.setError("未获取到接口响应");
            } else if (response.getError() != null) {
                result.setError("接口返回错误:" + response.getErrorMessage());
            } else {
                result.setText(cleanText(response.getText()));
                if (response.getUsage() != null) {           // 记录 Token 消耗
                    result.setPromptTokens(response.getUsage().getPromptTokens());
                    result.setCompletionTokens(response.getUsage().getCompletionTokens());
                    result.setTotalTokens(response.getUsage().getTotalTokens());
                }
            }
        } catch (HttpStatusCodeException e) {
            String body = e.getResponseBodyAsString(StandardCharsets.UTF_8);
            if (e.getStatusCode().value() == 401) {
                result.setError("API Key 无效或未配置(401)...");
            } else if (body.contains("50507")) {
                result.setError("上游返回 50507(提示词过长被拒),请改用 OCR: 等极简提示词...");
            } else {
                result.setError("调用失败 (" + e.getStatusCode() + "):" + body);
            }
        } catch (Exception e) {
            result.setError("调用异常:" + e.getMessage());
        }
        return result;
    }
}

几个容易被忽略但很关键的点:

  1. temperature = 0:OCR 是「转录」任务,不是创作,必须关掉随机性。
  2. max_tokens:默认 2048 够大部分单页;多页长文档要调大,否则文本被截断。
  3. 读取超时要放大 :VLM 推理比纯文本慢得多,我在 RestTemplateConfig 里给了 connect 30s / read 180s。
  4. Token 统计 :usage 字段直接透传到页面,方便观察成本。
  5. 401 / 50507 单独提示:把上游错误翻译成人话,能省掉大量排查时间(下篇详述)。
java 复制代码
@Configuration
public class RestTemplateConfig {
    @Bean
    public RestTemplate restTemplate() {
        SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
        factory.setConnectTimeout(30_000);
        factory.setReadTimeout(180_000);   // VLM 推理慢,读取超时必须放大
        return new RestTemplate(factory);
    }
}

4.4 控制器:一套逻辑,两种入口

/ocr 用于无 JS 的整页回退 ,/ocr/ajax 给前端 fetch 用,二者复用同一个 doRecognize:

java 复制代码
@PostMapping(value = "/ocr/ajax", produces = "application/json;charset=UTF-8")
@ResponseBody
public OcrResult ocrAjax(@RequestParam(value = "file", required = false) MultipartFile file,
                         @RequestParam(value = "imageUrl", required = false) String imageUrl,
                         @RequestParam(value = "prompt", required = false) String prompt,
                         @RequestParam(value = "apiKey", required = false) String apiKey,
                         @RequestParam(value = "model", required = false) String modelParam) {
    boolean hasFile = file != null && !file.isEmpty();
    boolean hasUrl  = StringUtils.hasText(imageUrl);
    if (!hasFile && !hasUrl) {
        OcrResult r = new OcrResult();
        r.setError("请上传图片或填写图片 URL");
        return r;
    }
    return doRecognize(file, imageUrl, prompt, apiKey, modelParam);
}

private OcrResult doRecognize(MultipartFile file, String imageUrl, String prompt,
                              String apiKey, String modelParam) {
    OcrResult result;
    String displaySrc;
    if (file != null && !file.isEmpty()) {
        byte[] bytes = file.getBytes();
        UUID id = imageCache.put(bytes, file.getContentType());   // 内存缓存,供回显
        displaySrc = "/api/image/" + id;
        result = ocrService.recognize(bytes, file.getContentType(), prompt, apiKey, modelParam);
    } else {
        displaySrc = imageUrl;
        result = ocrService.recognizeUrl(imageUrl, prompt, apiKey, modelParam);
    }
    result.setImageSrc(displaySrc);   // 前端用它渲染「原图预览」
    return result;
}

图片回显的小技巧 :上传的图片只存在于内存里,为了让页面右侧能同时展示原图与识别文本,我用一个 ConcurrentHashMap 缓存字节数组并生成 /api/image/{id} 地址,而不是把 base64 塞回前端。

java 复制代码
@Service
public class ImageCache {
    private final Map<UUID, ImageEntry> store = new ConcurrentHashMap<>();
    public UUID put(byte[] data, String mediaType) {
        UUID id = UUID.randomUUID();
        store.put(id, new ImageEntry(data, mediaType));
        return id;
    }
    public ImageEntry get(UUID id) { return store.get(id); }
    // ImageEntry:data + mediaType
}

4.5 页面:Thymeleaf + 一点原生 JS

页面只做三件事:提交、渲染、防抖。

html 复制代码
<!-- 提示词预设下拉:配置驱动,选空则恢复默认 -->
<select id="promptPreset" class="preset-select" th:attr="data-default=${defaultPrompt}">
    <option value="">--- 选择预设模板(可恢复默认) ---</option>
    <option th:each="preset : ${promptPresets}"
            th:value="${preset.value}" th:text="${preset.name}"></option>
</select>
javascript 复制代码
form.addEventListener('submit', function (e) {
    e.preventDefault();
    btn.disabled = true;                       // 防重复点击
    btn.textContent = '识别中...';
    document.getElementById('overlay').style.display = 'flex';
    fetch('/ocr/ajax', { method: 'POST', body: new FormData(form) })
        .then(r => r.json())
        .then(data => renderResult(data))
        .catch(err => showError('请求失败:' + err.message))
        .finally(() => { btn.disabled = false; btn.textContent = '开始识别';
                         document.getElementById('overlay').style.display = 'none'; });
});

识别结果是 Markdown,用 marked.js 一行渲染即可支持标题、列表和表格:

javascript 复制代码
out.innerHTML = window.marked ? marked.parse(text) : text;

五、跑起来

bash 复制代码
# 方式一:直接跑
mvn spring-boot:run

# 方式二:打包后跑(若 8080 被占用,用 --server.port 换端口)
mvn clean package
java -jar target/siliconflow-paddleocr-demo-1.0.0.jar --server.port=8088

访问 http://localhost:8080(或 8088):

  1. 左侧上传一张截图或填写图片 URL;
  2. 提示词保持默认 OCR:;
  3. 点击「开始识别」,右侧会同时出现原图预览与 Markdown 结果,并显示 Token 消耗。

图 5 应用首页:左输入、右结果(未识别时的空状态)


图 6 识别结果:原图预览 + Markdown 表格 + Token 消耗

CodeBuddy 在这一步也很省事:我让它「构建并重启服务,验证首页返回 200 且包含表单与遮罩」,它会自己执行 Maven、杀掉旧进程、启动新进程并做一次 HTTP 探活。


六、上篇小结

到这里,一个能跑、能用、结构清晰的 OCR 应用已经成型,核心代码不到 400 行。回顾一下关键决策:

决策 理由
选 SiliconFlow 的 OpenAI 兼容接口 零部署成本,Java 侧一个 RestTemplate 搞定
提示词、模型、Key 全部外置到 yml 业务同学也能调,不用改代码
预设模板用配置列表驱动页面下拉 加模板零代码改动
表单提交 + AJAX 双入口 兼顾无 JS 回退与交互体验
用 CodeBuddy 做全链路迭代 需求/报错/截图都能直接喂给它,跨文件改造一致性高

但「能跑」离「好用」还有距离,真实使用中我遇到了这些麻烦:

  • 预设模板的中文在页面上全是乱码;
  • 提示词写长了,上游直接 500 / code 50507;
  • 某些提示词会让模型吐出几百个 <|LOC_x|> 坐标噪声;
  • 页面上下堆叠,识别长文档时要来回滚动对照原图。

这些问题的实测数据、修复过程与优化后的布局方案,我会放在下篇展开,同时给出一份「和 CodeBuddy 协作的最佳实践」与生产化改造清单。


下篇预告 :《CodeBuddy × 硅基流动:PaddleOCR-VL 提示词调优、体验打磨与生产化改造(下篇)》

包含:6 组提示词对照实测、application.properties 乱码根因与修复、<|LOC_x|> 噪声清洗、左右分栏布局改造、踩坑清单、CodeBuddy 协作方法论、异步化与成本优化。

相关推荐
拉你进_教堂3 天前
小龙虾技能之【Contactless Health Risk Screening Tool | 非接触式健康风险检测分析工具】简介
人工智能·目标检测·计算机视觉·多模态大模型·openclaw小龙虾技能
拉你进_教堂4 天前
小龙虾技能之【Basic Object Detection Skill | 基础目标检测技能】简介
人工智能·目标检测·计算机视觉·多模态大模型·openclaw小龙虾技能
小草cys11 天前
MiniMax H3 和 Wan2.2的对比
大模型·图像生成·多模态大模型·视频生成·minimax·wan
拉你进_教堂18 天前
小龙虾技能之【Intelligent Public Smoking Detection Skill | 公共场所吸烟行为智能检测技能】简介
人工智能·计算机视觉·多模态大模型·openclaw小龙虾技能
拉你进_教堂24 天前
小龙虾技能之【Parkinson‘s & Epileptic Behavior Recognition Skill | 帕金森癫痫行为识别技能】简介
人工智能·计算机视觉·多模态大模型·openclaw小龙虾技能
论文复现现场24 天前
MiniCPM-o 4.5 需要多少显存?RTX 3090 24GB 的 BF16、AWQ、GGUF 部署边界与验收方法
cuda·多模态大模型·显存优化·rtx3090·minicpm-o
挖掘狂人24 天前
用 AI 写代码别只甩一句指令,这套工作流救过我的项目
程序员·ai编程·codebuddy
HyperAI超神经1 个月前
SenseNova-U1.5-8B-MoT 统一生成与理解,解锁原生多模态创作;DeepSeek-V4-Flash-Vision-Exp 拓展视觉理解新能力
人工智能·深度学习·图像生成·多模态大模型·视觉推理
OpenBayes2 个月前
Qwen3.8-27B-FP8 降低大模型部署门槛,开启高效多模态智能体验;MiniMax H3-1K 发布千段视频测试数据集,全面评估 AI 视频生成能力
人工智能·深度学习·计算机视觉·自然语言处理·语音识别·多模态大模型