摘要 :本篇记录我如何用 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;
}
}
几个容易被忽略但很关键的点:
temperature = 0:OCR 是「转录」任务,不是创作,必须关掉随机性。max_tokens:默认 2048 够大部分单页;多页长文档要调大,否则文本被截断。- 读取超时要放大 :VLM 推理比纯文本慢得多,我在
RestTemplateConfig里给了connect 30s / read 180s。 - Token 统计 :
usage字段直接透传到页面,方便观察成本。 - 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):
- 左侧上传一张截图或填写图片 URL;
- 提示词保持默认
OCR:; - 点击「开始识别」,右侧会同时出现原图预览与 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 协作方法论、异步化与成本优化。