摘要 :承接上篇。上篇我们把工程跑通了,本篇解决「能用 」到「好用 」之间的四个真实问题:预设模板中文乱码、提示词过长被上游拒绝(500 / code 50507)、模型输出
<|LOC_x|>坐标噪声、页面上下堆叠不好对照。同时给出一份与 CodeBuddy 协作的实战方法论,以及面向生产的改造清单与成本参考。
一、踩坑一:页面上的中文预设全是乱码
1.1 现象
application.properties 里写的是:
properties
ocr.prompt-presets[1].name=表格 / 截图结构化(约 60 字)
页面上却显示成 è¡¨æ ¼ / æªå¾... 之类的一串乱码,而同一个文件里英文配置项完全正常 。

1.2 根因
Java 的 java.util.Properties 规范规定:.properties 文件默认按 ISO-8859-1 读取,非 ASCII 字符必须写成 \uXXXX 转义。Spring Boot 的 PropertiesPropertySourceLoader 遵循这一规范,所以:
- 文件以 UTF-8 落盘;
- 被以 ISO-8859-1 解码;
- 每个 UTF-8 字节被当成一个 Latin-1 字符 → 乱码。
这也是为什么很多人「在 properties 里写中文没问题」------他们要么用了 IDE 自动转义,要么根本没在页面上展示这些值。
1.3 修复:迁移到 application.yml
SnakeYAML 默认按 UTF-8 解析,配置直接可读可写,一劳永逸:
yaml
ocr:
default-prompt: "OCR:"
strip-loc-tokens: true
prompt-presets:
- name: "OCR:(官方推荐,最准)"
value: "OCR:"
- name: "表格 / 截图结构化(约 60 字)"
value: "请把图片中的文字逐行完整识别出来,表格输出为 Markdown 表格,数字与单位原样保留,不要解释。看不清的字符用 □ 代替。"
迁移时要注意三点:
- 删除旧的
application.properties,否则两份配置都会被加载,同名键的覆盖顺序容易让人困惑; - 含
:、#、{、}的值一律用双引号包起来(下篇的 JSON 预设就是典型); - 把代码注释、报错文案、
README.md里的文件名引用一起同步 ------这一步直接交给 CodeBuddy:「把工程里所有application.properties的引用改成application.yml」,它会连注释一起改干净。
如果团队规范必须用
.properties,那就用native2ascii把中文转成\uXXXX,效果等价,只是不可读。

图 1、图片信息正常展示图
二、踩坑二:提示词写长了,上游直接 500 / code 50507
2.1 同一张截图的对照实测
我拿同一张「控制台配额」截图,只改提示词,其余参数不变(temperature=0、max_tokens=2048),结果如下:
| # | 提示词 | 长度 | 结果 |
|---|---|---|---|
| 1 | OCR:(官方) |
4 字 | ✅ 最优 :约 2s / 1192 token / 541 字,无噪声,未在线购买、0.5万次/天、72/3QPS 全部正确 |
| 2 | 请把图片中的文字逐行完整识别出来,表格输出为 Markdown 表格,数字与单位原样保留,不要解释。看不清的字符用 □ 代替。 | 63 字 | ✅ 干净可用 |
| 3 | 同上并追加「①②③ 编号、<br> 换行」等约束 |
93 字 | ✅ 干净(已接近上限) |
| 4 | 同类更长的约束句 | 106 / 162 / 190 / 324 字 | ❌ HTTP 500 / code 50507,直接被拒 |
| 5 | 请识别图片中的所有文字。 / 请逐行识别图片中的全部文字。 |
12~14 字 | ⚠️ 输出 500+ 个 `< |
2.2 结论(可直接抄)
- 优先用官方极简提示词
OCR:------准确率最高、输出最干净、token 消耗最低(比长提示词省 10%+); - 中文提示词控制在 ≤ 60 字,实测上限约 100 字,超过必 500;
- 短 ≠ 安全:第 5 组只有 12 字却触发了坐标噪声,说明「长度」与「表述方式」都要实测;
- 把结论沉淀进配置 + 文档,而不是散落在代码里。
2.3 把结论固化到产品里
① 预设模板配置化(页面下拉直接由它驱动,加模板不用改代码):
yaml
ocr:
prompt-presets:
- name: "OCR:(官方推荐,最准)"
value: "OCR:"
- name: "表格 / 截图结构化(约 60 字)"
value: "请把图片中的文字逐行完整识别出来,表格输出为 Markdown 表格,数字与单位原样保留,不要解释。看不清的字符用 □ 代替。"
- name: "强约束长句(约 93 字,接近上限)"
value: "请把图片中的文字逐行完整识别出来,表格输出为 Markdown 表格,数字与单位原样保留,不要解释。输出时用 ① ② ③ 编号,单元格内换行用 <br> 表示,看不清的字符用 □ 代替。"
② 前端实时字数提醒(超过 100 字标红):
javascript
function refreshCount() {
var len = promptEl.value.length;
counter.textContent = '当前 ' + len + ' 字';
counter.classList.toggle('over', len > 100); // .over { color: #b91c1c }
}
③ 把上游错误翻译成人话,让使用者自己就能定位:
java
} else if (body.contains("50507")) {
result.setError("上游返回 50507(请求被拒)。实测最常见原因是提示词过长:"
+ "PaddleOCR-VL 建议使用极简提示词(如 OCR:),提示词约 100 字以上会被直接拒绝。"
+ "请缩短提示词或改用页面预置模板后重试。上游返回:" + truncate(body));
}
这一步我是直接把 50507 的原始报错贴给 CodeBuddy,让它「定位原因并给出用户可读的提示」,它同时改了 Service 的错误分支和页面上的提示文案。
三、踩坑三:模型吐出一堆 <|LOC_x|> 坐标噪声
某些提示词下,模型会把版面坐标一起输出:
<|LOC_12|>已使用:0%<|LOC_45|>包月:未在线购买<|LOC_78|>...

图 2 模型输出中的 `<|LOC_x|>` 坐标噪声
这是模型的版面定位标记,对下游做坐标还原有用,但对「只要文本」的场景就是噪声。
处理方式做成可配置开关,默认开启过滤:
java
private static final Pattern LOC_TOKEN_PATTERN = Pattern.compile("<\\|LOC_\\d+\\|>");
@Value("${ocr.strip-loc-tokens:true}")
private boolean stripLocTokens;
private String cleanText(String text) {
if (text == null) return null;
String cleaned = stripLocTokens ? LOC_TOKEN_PATTERN.matcher(text).replaceAll("") : text;
return cleaned.trim();
}
yaml
ocr:
strip-loc-tokens: true # 需要坐标信息时改为 false
经验:这类「模型特有输出」一定要做成开关,而不是写死过滤。哪天你要做版面还原或图文对照,把开关一关就能拿到原始坐标。
四、体验打磨:把「识别结果」挪到右侧
4.1 问题
原始布局是上下堆叠 :表单在最上,识别时页面要往下滚,对照原图时又要往上滚,长文档体验很差。

图 3 改造前:表单与结果上下堆叠
我给 CodeBuddy 的指令很简单:
「修改:1、预设模板中文有乱码,需要调整。2、将页面布局调整一下,图片预览和结果展示调整为右侧展示。信息输入保留在左侧」
4.2 目标布局
┌─────────────────────────────────────────────────────┐
│ 提示 / 错误条(整行) │
├──────────────────┬──────────────────────────────────┤
│ 信息输入(400px)│ 图片预览与识别结果(自适应) │
│ · 上传图片 │ ┌────────────────────────────┐ │
│ · 图片 URL │ │ 原图预览 │ │
│ · 提示词 + 预设 │ └────────────────────────────┘ │
│ · 模型 / Key │ ┌────────────────────────────┐ │
│ · [开始识别] │ │ 识别文本(Markdown) │ │
│ │ └────────────────────────────┘ │
└──────────────────┴──────────────────────────────────┘
4.3 实现:不动 DOM 结构,纯 CSS 分栏
关键技巧是不给两栏套 wrapper ,直接让 .container 变成 flex 容器,用 order 把提示条排到前面:
css
.container {
max-width: 1180px;
margin: -28px auto 40px;
padding: 0 20px;
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 22px;
}
/* 左栏:信息输入 */
.col-left { flex: 0 0 400px; max-width: 400px; }
/* 右栏:图片预览 + 识别结果 */
.col-right { flex: 1 1 460px; min-width: 0; }
/* 提示条独占整行,并排在左右两栏之前 */
.container > .alert { flex: 0 0 100%; margin-bottom: 0; }
.container > .alert-error { order: -1; }
.container > .card { margin-bottom: 0; }
/* 右栏内纵向排布:上方原图,下方文本 */
.result-grid { display: grid; grid-template-columns: 1fr; gap: 20px; }
@media (max-width: 900px) {
.container { flex-direction: column; }
.col-left, .col-right { flex: 1 1 auto; width: 100%; max-width: none; }
}
三个细节值得抄:
min-width: 0:不加它,右栏里的长文本 / 宽表格会把 flex 子项撑破,导致整体横向溢出;.container > .alert-error { order: -1 }:错误提示在 DOM 里位于两栏之间,用order把它视觉上提到最前面,避免它把右栏挤到下一行;- 媒体查询里
max-width: none:否则移动端仍受 400px 限制。
HTML 侧只需加类名:
html
<section class="card col-left">
<h2 class="card-title">信息输入</h2>
<form id="ocrForm" ...>...</form>
</section>
<section class="card result col-right" id="resultSection">
<h2 class="card-title">图片预览与识别结果</h2>
<div id="resultError" class="alert alert-error" ...></div>
<!-- 无结果时的空状态 -->
<div id="resultEmpty" class="result-empty" th:style="${result} ? 'display:none' : 'display:block'">
<svg width="52" height="52" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="3"/>
<circle cx="8.5" cy="8.5" r="1.6"/>
<path d="M21 15l-5-5L5 21"/>
</svg>
<p>提交左侧表单后,原图预览与识别文本将在这里展示。</p>
</div>
<div class="result-grid" id="resultGrid" ...>
<div class="result-image">...原图预览...</div>
<div class="result-text">...识别文本...</div>
</div>
</section>

图 4 左输入、右预览与结果的分栏布局
右栏常驻显示 + 空状态占位,比「有结果才出现整块」更稳,页面不会在提交前后发生高度跳动。JS 里记得在拿到结果后隐藏占位:
javascript
function hideEmpty() {
var empty = document.getElementById('resultEmpty');
if (empty) empty.style.display = 'none';
}
五、踩坑清单(速查表)
| # | 现象 | 根因 | 修复 |
|---|---|---|---|
| 1 | 页面中文预设乱码 | .properties 按 ISO-8859-1 解码 |
迁移 application.yml(UTF-8)或 \uXXXX 转义 |
| 2 | HTTP 500 / code 50507 |
提示词过长(约 >100 字)被上游拒绝 | 用 OCR:,中文提示词 ≤ 60 字,页面加字数提醒 |
| 3 | 输出大量 `< | LOC_x | >` |
| 4 | 401 Unauthorized |
Key 无效/未配置/带了占位符 | 专项提示 + 页面允许临时覆盖 Key |
| 5 | 偶发超时 | VLM 推理慢,默认读超时太短 | RestTemplate read timeout 提到 180s |
| 6 | 大图上传失败 | Spring 默认 multipart 1MB | spring.servlet.multipart.max-file-size=15MB |
| 7 | 重启后原图预览 404 | 图片缓存在内存 ConcurrentHashMap |
生产改为对象存储 / 落库(见第七节) |
| 8 | 端口起不来 | 8080 被其他进程占用 | java -jar app.jar --server.port=8088 |
六、和 CodeBuddy 协作的 7 条实战经验
这一节是我这次全程使用下来的真实体会,比任何「提示词模板」都更有用:
- 一次把需求说清:技术栈 + 功能点 + 约束 + 交付形态。第一轮给的信息越完整,返工越少(见上篇 3.1)。
- 报错直接贴原文 :把
HTTP 500 / code 50507整段贴给它,比自己描述「接口报错了」有效得多------它会定位到具体分支并改出可读的错误提示。 - 截图是最高效的需求文档:「按这张截图优化提示词」「把结果挪到右侧」这类需求,一张图胜过三百字描述。
- 让它做跨文件的同步修改 :改配置文件名时,我会明确要求「同步更新代码注释、报错文案和 README」,它能保证一致性,人肉改最容易漏。
- 让它自己验证:「构建并重启服务,验证首页返回 200 且包含表单与遮罩」------它会跑 Maven、杀旧进程、起新进程、做 HTTP 探活,把「改完没验证」的空白补上。
- 要求它解释原因而不只是给方案:乱码那次我追问了「为什么 properties 会乱码」,得到的 ISO-8859-1 解释让我后续所有配置选型都更笃定。
- 增量优于重写:明确说「只改这两处,其余保持不变」,可以避免它顺手重构你没要求动的代码。
七、生产化改造清单
演示工程和生产之间,差的是下面这些(按优先级排序):
| 优先级 | 改造项 | 说明 |
|---|---|---|
| P0 | Key 外置 | 用环境变量 SILICONFLOW_API_KEY 或配置中心,绝不进 Git;.gitignore 里排除本地配置 |
| P0 | 异步化 | 单次 VLM 推理 2~10s,改用 @Async / 消息队列 + 任务 ID 轮询,避免请求线程被长时间占满 |
| P0 | 超时与重试 | 连接池化(HttpClient + RestTemplate)、读超时 180s、对 5xx 做有限重试与退避 |
| P1 | 图片存储 | 内存 ImageCache 换 OSS/COS/MinIO,返回可长期访问的 URL;或直接返回前端临时链接 |
| P1 | 结果缓存 | 对图片内容做哈希去重,相同图片直接复用结果,省 token |
| P1 | 结构化输出 | 需要程序消费时,提示词要求输出 JSON({"title":...,"tables":[...]}),并用校验 + 重试保证可解析 |
| P2 | 批量 / PDF | PDF 按页拆图并发调用,最后合并 Markdown |
| P2 | 限流与配额 | 按用户/租户限流,记录每次调用的 token 消耗,做成本看板 |
| P2 | 可观测性 | 打点:耗时、token、成功率、上游错误码分布;对 50507 / 401 配置告警 |
| P2 | 降级 | 上游不可用时回退到传统 OCR 流水线,保证主流程可用 |
异步化的最小改造示意:
java
@PostMapping("/ocr/task")
@ResponseBody
public String submit(MultipartFile file) {
String taskId = UUID.randomUUID().toString();
taskStore.put(taskId, TaskState.RUNNING);
CompletableFuture.runAsync(() -> {
OcrResult r = ocrService.recognize(...);
taskStore.put(taskId, r);
});
return taskId; // 前端轮询 /ocr/task/{id}
}
八、成本与性能参考
以本文的单页截图场景为例(PaddleOCR-VL-1.5,temperature=0,max_tokens=2048):
| 指标 | 实测值 |
|---|---|
| 端到端耗时 | 约 2s(单页截图,不含网络抖动) |
| Token 消耗 | 约 1192(图片 + 输出,具体随图片分辨率与文本量浮动) |
| 输出长度 | 约 541 字 |
| 长提示词相对开销 | 比 OCR: 多约 10%+ prompt token,且有被拒风险 |
成本优化的三个抓手:
- 用最短提示词(省 token,还更准);
- 图片预处理:裁掉无关区域、适度降分辨率,能显著降低 image token;
- 结果缓存 + 去重:重复图片直接命中缓存。
硅基流动对不同模型的计费与免费额度会调整,请以官网控制台为准;本文数据仅用于横向对比提示词优劣。
九、结语
回头看,这个 Demo 真正的价值不在于那 400 行代码,而在于验证了一种开发方式:
- 模型侧 :硅基流动把「多模态大模型」变成了一次 HTTP 调用,Java 工程师不需要碰 Python、不需要管 GPU;
- 工具侧 :CodeBuddy 把「需求 → 工程 → 调优 → 排错 → 验证」串成了一条自然语言驱动的流水线,尤其擅长跨文件同步改造和「贴报错/发截图」这类高信息密度的输入;
- 工程侧 :真正决定效果好坏的是提示词与工程细节(编码、超时、错误翻译、布局),这些恰恰是 AI 助手最能帮你提速、也最容易被忽视的地方。
如果你也想复现,建议按这个顺序来:
- 先拿 Key 用
curl打通一次 SiliconFlow 接口; - 把上篇 3.1 的需求提示词交给 CodeBuddy 生成骨架;
- 用
OCR:跑通第一张图,再按本篇第二节做提示词实测; - 最后补齐第七节的 P0 项再上生产。