结构恢复
这篇手册处理的是丢了格式的纯文本:段落被按固定宽度硬换行,标题没有 #,列表没有 - 和 1.,代码块没有围栏,提示框也没有标记。目标是把它还原成 Markdown:标题、正文、列表、代码、引文、提示框。
用生成式大模型"改写成 Markdown"当然可以,但改写也可能顺手改字。这里模型一个字都不写:它只回答一些很窄的问题(这一行是不是接着上一行那句话写的?这个块是什么内容?),渲染交给代码,所以输出的每一个字都来自输入,每一个判断都带着概率。
整条流水线每篇文档两次请求,先后发出:
- 第一遍,拼行: 每对相邻的行问一个 Noul(是非题,答案是"是"的概率):"这一行是不是从一句话的中间接着写的?"所有行对放进同一个请求;判为续写的行拼回同一个块。
- 第二遍,分类: 每个拼好的块问一个 Choice(单选题,每个选项都有概率):标题、正文、列表项、引文、代码还是提示框。块要等第一遍答完才存在,所以这是第二个请求;同一个请求里还顺带问了每个块的附带问题(标题层级、是否有序步骤、提示框种类),只有类型用得上时才读。
- 能直接看出来的留在代码里。 空行在代码里读,不交给模型重新判断。
我们在 Vue 中文文档(CC BY 4.0)上构造了 43 段带标准答案的测试文本,13 段用来选阈值和写法,30 段只用来报告。结论先放在这里:
- 拼行做得好。 测试集 629 个行对,象信判对 93.5%(41 处错),只看行尾标点的规则判对 80.9%(120 处错)。
- 分类不如规则。 按字符计的块类型正确率,象信 72.4%,一条利用空行和标点的简单规则 86.0%。象信一号 1.0 在"这是标题还是列表项"这种抽象的版式判断上把握很低(类型题平均置信度 0.35),有序列表一个也没认出来。
- 组合起来最好。 用象信拼行、用规则分类,字符正确率 87.1%,块完全还原率 79.9%(规则 62.9%,象信两遍 63.7%)。
两次请求的费用约为每段 ¥0.00043(平均 10,327 个输入 token)。数字的来源和局限见下文与附录。
环境准备
pip install xiangxin-sdk
export XIANGXIN_API_KEY="sk-xx-..."完整代码在 pipeline.py(问题定义、渲染、评测)和 main.py(跑全部实验)里。所有请求都缓存在 results/api_cache.json,重跑脚本会复现本文的数字;删掉这个文件即重新调用 API。
from xiangxin import Choice, Noul, XiangxinClient
MODEL = "xiangxin-latest"
PRICE_PER_M_INPUT = 0.042 # 元 / 百万输入 token,输出免费
client = XiangxinClient(timeout=120.0)数据:去掉格式的 Vue 中文文档
TypeSafe 原文用的是一份手写的备忘录。为了能算准确率,我们需要有标准答案的文本,于是反过来做:拿真实的 Markdown,程序化地把格式去掉,再看能还原多少。
- 来源: vuejs-translations/docs-zh-cn 的
src/guide/**/*.md,固定在提交dda601fe,许可证 CC BY 4.0(图片除外,本文未使用图片)。 - 切分: 每段是「页面标题 + 一个二级章节」。含表格、折叠块、代码组的章节整节跳过;纯文本 300–2,200 字、至少 4 个块、含列表/代码/提示框/引文之一、代码行不超过三成的章节入选,共 43 段。
- 去格式(
prepare_data.py): 去掉#、列表符号、代码围栏、:::tip容器、>、行内的链接/加粗/反引号;只保留组合式 API 的写法;代码块最多保留前 4 行。正文、标题、列表项按显示宽度 60(约 30 个汉字)硬换行,标点不放行首。块与块之间留空行,但列表项之间、代码行之间、引出列表的那句话与列表之间不留,这和 TypeSafe 原文里那份备忘录的状态一致。 - 标准答案: 每一行属于哪个块、每个块的类型;标题分文档标题 / 章节 / 小节,提示框按 Vue 文档的
tip/info/warning/danger映射为建议 / 备注 / 警告。 - 划分: 下文逐步演示的那一段(
essentials/event-handling的「按键修饰符」一节,事先按"块类型最多"挑出,没有看结果)加上随机顺序的前 12 段为开发集,其余 30 段为测试集。测试集共 953 个非空行、488 个标准块:标题 107、正文 177、列表项 150、代码 43、提示框 8、引文 3。
演示段落去格式之后的开头是这样的:
事件处理
按键修饰符
在监听键盘事件时,我们经常需要检查特定的按键。Vue 允许在
v-on 或 @ 监听按键事件时添加按键修饰符。
<input @keyup.enter="submit" />
你可以直接使用 KeyboardEvent.key 暴露的按键名称作为修饰符,
但需要转为 kebab-case 形式。切行、记录空行、编号都在代码里完成,不涉及模型。每一行加上一个短编号(L014| ),编号就是状态里的普通文字,问题和答案都用它指代行(和逐行检索的做法一样):
import re
def to_lines(text: str) -> list[dict]:
lines, gap = [], False
for raw in text.split("\n"):
stripped = re.sub(r"[\t ]+", " ", raw).strip()
if not stripped:
gap = bool(lines) # 开头的空行不算分隔
continue
lines.append({"text": stripped, "gap": gap})
gap = False
return lines
def tag(items: list[dict], prefix: str) -> str:
return "\n".join(
f"{chr(10) if item['gap'] else ''}{prefix}{i:03d}| {item['text']}"
for i, item in enumerate(items)
)
def line_id(i: int) -> str:
return f"L{i:03d}"
def block_id(i: int) -> str:
return f"B{i:03d}"演示段落有 45 个非空行,模型看到的状态是:
L000| 事件处理
L001| 按键修饰符
L002| 在监听键盘事件时,我们经常需要检查特定的按键。Vue 允许在
L003| v-on 或 @ 监听按键事件时添加按键修饰符。
L004| <input @keyup.enter="submit" />第一遍:拼回被拆开的句子
每对相邻的行问一个 Noul,全部放进一个请求;中间隔着空行的行对不问。问题刻意问得很窄("是不是从句子中间接着写的"),这几乎是关于文本的客观事实。和 TypeSafe 原文不同的一点是:问题里直接带上两行原文,而不只写行号。原因见附录「为什么问题里要带原文」。
JOIN_CRITERIA = {
"true": "本行开头处在一句话的中间:上一行开始的句子被换行硬生生拆成了两半",
"false": "本行自己开启一句新话、一个新的列表项、一个标题或一段新内容",
}
def join_question(i: int, lines: list[dict]) -> Noul:
return Noul(
instructions={
"上一行": f"{line_id(i - 1)}| {lines[i - 1]['text']}",
"本行": f"{line_id(i)}| {lines[i]['text']}",
"问题": "本行是否是从一句话的中间接着写的,即上一行末尾那句话还没写完,被换行拆开,在本行继续?",
},
criteria=JOIN_CRITERIA,
)
def stitch(lines: list[dict]) -> list[float]:
questions = {line_id(i): join_question(i, lines)
for i in range(1, len(lines)) if not lines[i]["gap"]}
resp = client.system_one(state=tag(lines, "L"), questions=questions, model=MODEL)
return [resp.answers[line_id(i)].noul if line_id(i) in resp.answers else 0.0
for i in range(len(lines))]合并的门槛取决于上一行怎么结尾:上一行没有句末标点(悬空)时,概率 ≥ 0.35 就拼;上一行以 。!?:;… 等结尾时,门槛升到 0.5。这两个数是在开发集上网格搜索出来的(附录有说明)。中文拼接时直接相接,只有两侧都是西文字符时才补回被换行吃掉的空格。
JOIN_AFTER_DANGLING, JOIN_AFTER_TERMINAL = 0.35, 0.5
TERMINAL = re.compile(r"[。!?:;…\.!?:;][\"”’」』))\]]*$")
def join_text(a: str, b: str) -> str:
if re.search(r"[A-Za-z0-9_.,;:)\]}'\"]$", a) and re.match(r"[A-Za-z0-9_(\[{'\"$@.]", b):
return a + " " + b
return a + b
def merge(lines: list[dict], joins: list[float]) -> list[dict]:
blocks = []
for i, line in enumerate(lines):
after_terminal = i and TERMINAL.search(lines[i - 1]["text"])
bar = JOIN_AFTER_TERMINAL if after_terminal else JOIN_AFTER_DANGLING
if blocks and not line["gap"] and joins[i] >= bar:
blocks[-1]["text"] = join_text(blocks[-1]["text"], line["text"])
blocks[-1]["lines"].append(i)
else:
blocks.append({"text": line["text"], "lines": [i], "gap": line["gap"]})
return blocks演示段落:27 个拼接问题,一次请求;45 行拼成 34 块(接回 11 处断行),和标准答案的 34 块一一对应,没有一处拼错或漏拼。下面是部分行的续写概率("标准"一列是答案:接 = 被硬换行拆开,断 = 本来就分开):
续写概率 标准 行
L002| 在监听键盘事件时,我们经常需要检查特定的按键。Vue 允许在
0.44 接 L003| v-on 或 @ 监听按键事件时添加按键修饰符。
L005| 你可以直接使用 KeyboardEvent.key 暴露的按键名称作为修
0.47 接 L006| 但需要转为 kebab-case 形式。
L011| Vue 为一些常用的按键提供了别名:
0.31 断 L012| .enter
0.23 断 L013| .tab
0.24 断 L014| .delete (捕获“Delete”和“Backspace”两个按键)
L028| 在 Mac 键盘上,meta 是 Command 键 (⌘)。在 Win
0.49 接 L029| meta 键是 Windows 键 (⊞)。在 Sun 微机系统键盘上,
0.51 接 L030| 键 (◆)。在某些键盘上,特别是 MIT 和 Lisp 机器的键盘及其后
L034| <input @keyup.alt.enter="clear" />
0.26 断 L035| <div @click.ctrl="doSomething">Do so
L036| 请注意,系统按键修饰符和常规按键不同。与 keyup 事件一起使用
0.58 接 L037| 时,该按键必须在事件发出时处于按下状态。换句话说,keyup.ctrl真正被拆开的行落在 0.44–0.61,本来就分开的列表项和代码行落在 0.18–0.33。两群之间有间隔,但远不如 TypeSafe 原文里那么"接近 0 和接近 1":象信一号 1.0 在这道题上给出的概率普遍偏向中间。
在 30 段测试集(629 个行对,其中 465 个应当拼接)上:
| 方法 | 准确率 | 精确率 | 召回率 | 错误数 |
|---|---|---|---|---|
| 象信(问题带原文,阈值 0.35 / 0.5) | 93.5% | 96.5% | 94.6% | 41 |
| 象信(同上,只用一个门槛 0.35) | 90.1% | 90.7% | 96.6% | 62 |
| 象信(同上,套用 TypeSafe 原文的阈值 0.2 / 0.5) | 84.6% | 84.8% | 96.3% | 97 |
| 规则:上一行没有句末标点就拼 | 80.9% | 81.7% | 95.7% | 120 |
规则的问题出在不带标点的列表项上(.enter、.tab、Solid 信号……),它们会被一股脑拼成一行;象信能看出这些是彼此并列的短条目。直接套 TypeSafe 的 0.2 门槛则太松:象信一号给"本来就分开"的行打出的概率大多在 0.2–0.33 之间。
第二遍:给每个块分类
每个拼好的块问一个 Choice:*这是什么内容?*下面三组选项描述,加上 classify_questions 里步骤题的 true / false 描述,就是分类器的全部规格。要把流水线用到你自己的文档上,改这些描述即可。
选项键名用中文,代码里再映射回内部类型名:已知短板提到,Choice 的选项键名是模型输入的一部分。块类型的描述在开发集上比较过三种写法,这组短描述最好(附录)。
TYPE_CRITERIA = {
"标题": "简短的标签或标题,为整篇文档或紧随其后的一节命名,不是完整的句子",
"正文": "连续的正文:一句或多句完整的说明或叙述",
"列表项": "并列条目中的一项,读起来是若干兄弟条目之一",
"引文": "归属于某人或某出处的引语、摘录",
"代码": "计算机代码、命令、终端输出或配置片段",
"提示框": "提示、警告或重要说明,提醒读者不要漏看",
}
HLEVEL_CRITERIA = {
"文档标题": "整篇文档的标题",
"章节标题": "文档里的一个主要章节标题",
"小节标题": "嵌套在某个章节之下的小节标题",
}
CALLOUT_CRITERIA = {
"备注": "中性的补充信息,读者应当知道",
"建议": "有帮助的建议或捷径,让事情更顺手",
"警告": "提醒某件事可能出错或造成损害",
}
TYPE_KEY = {"标题": "heading", "正文": "paragraph", "列表项": "list_item",
"引文": "quote", "代码": "code", "提示框": "callout"}下面是管道部分:生成问题、发一个请求、读回答案。类型是标题,渲染时要知道层级;是列表项,要知道是否有序;是提示框,要知道种类。这时类型还不知道,等它就要第三次往返,所以附带问题一开始就一起问。大部分附带答案不会被读取(正文的"步骤概率"没有意义,直接忽略)。
问题里引用块的原文,类型题最多引 120 字,附带题引 40 字。代码还先排除用不上的附带题:超过 40 字的块不可能渲染成标题,不问层级;超过 80 字的块不会是列表步骤;不含汉字或不足 15 字的块不会是提示框。这样也把最长的文档控制在单请求的 token 上限之内(仍然超限时,pipeline.py 会把问题对半拆成两个请求,状态不变)。
HEADING_MAX_CHARS, STEP_MAX_CHARS, CALLOUT_MIN_CHARS = 40, 80, 15
STEP_CRITERIA = {
"true": "它是某个操作流程中的一步——前后几条必须按顺序进行",
"false": "顺序无关——它是一组松散的并列项之一,或者根本不是列表项",
}
def clip(text: str, n: int) -> str:
return text if len(text) <= n else text[:n] + "…"
def classify_questions(texts: list[str]) -> dict:
questions = {}
for i, text in enumerate(texts):
bid = block_id(i)
def ask(q: str, n: int = 40) -> dict:
return {"块": f"{bid}| {clip(text, n)}", "问题": f"这个块{q}"}
questions[f"type_{bid}"] = Choice(instructions=ask("是什么类型的内容?", 120),
criteria=TYPE_CRITERIA)
if len(text) <= HEADING_MAX_CHARS:
questions[f"hlevel_{bid}"] = Choice(
instructions=ask("如果作为标题,在这篇文档的结构里处于哪一级?"),
criteria=HLEVEL_CRITERIA)
if len(text) <= STEP_MAX_CHARS:
questions[f"step_{bid}"] = Noul(
instructions=ask("是否是一串有先后顺序的步骤中的一条?"), criteria=STEP_CRITERIA)
if len(text) >= CALLOUT_MIN_CHARS and re.search(r"[一-鿿]", text):
questions[f"callout_{bid}"] = Choice(instructions=ask("属于哪一种旁注?"),
criteria=CALLOUT_CRITERIA)
return questions
def classify(blocks: list[dict]) -> None:
texts = [b["text"] for b in blocks]
resp = client.system_one(state=tag(blocks, "B"), questions=classify_questions(texts),
model=MODEL)
a = resp.answers
for i, b in enumerate(blocks):
bid = block_id(i)
t = a[f"type_{bid}"]
b["type"], b["confidence"] = TYPE_KEY[t.choice], t.confidence
b["probabilities"] = {TYPE_KEY[k]: v for k, v in t.probabilities.items()}
b["hlevel"] = {"文档标题": "title", "章节标题": "section", "小节标题": "subsection"}[
a[f"hlevel_{bid}"].choice] if f"hlevel_{bid}" in a else "section"
b["step"] = a[f"step_{bid}"].noul if f"step_{bid}" in a else 0.0
b["callout"] = {"备注": "note", "建议": "tip", "警告": "warning"}[
a[f"callout_{bid}"].choice] if f"callout_{bid}" in a else "note"演示段落:34 个块、100 个问题,一次请求。
块 类型 置信度 附带答案 文本
B000 heading 0.18 level=section 事件处理
B001 heading 0.20 level=subsection 按键修饰符
B002 paragraph 0.24 - 在监听键盘事件时,我们经常需要检查特定的按键。Vue 允许在
B003 code 0.56 - <input @keyup.enter="submit" /
B004 paragraph 0.18 - 你可以直接使用 KeyboardEvent.key 暴露的按
B007 list_item 0.21 step=0.23 按键别名
B008 list_item 0.28 step=0.24 Vue 为一些常用的按键提供了别名:
B009 list_item 0.29 step=0.25 .enter
…(B010–B017 同为 list_item)
B018 heading 0.15 level=subsection 系统按键修饰符
B019 paragraph 0.17 - 你可以使用以下系统按键修饰符来触发鼠标或键盘事件监听器,只有
B020 list_item 0.17 step=0.24 .ctrl
B024 paragraph 0.16 - 在 Mac 键盘上,meta 是 Command 键 (⌘)
B025 code 0.40 - 举例来说:
B026 code 0.64 - <input @keyup.alt.enter="clear
B028 paragraph 0.27 - 请注意,系统按键修饰符和常规按键不同。与 keyup 事件一
B029 code 0.15 - .exact 修饰符
B030 paragraph 0.18 - .exact 修饰符允许精确控制触发事件所需的系统修饰符的组大方向是对的:正文、代码、两组按键列表都认出来了。错的地方也很典型:
- 「按键别名」这个小节标题和引出列表的那句话都被当成了列表项(它们紧挨着列表);「.exact 修饰符」这个标题长得像代码,被判成了代码;「举例来说:」也被判成了代码(后面紧跟代码)。
- 标题层级偏了一级:文档标题「事件处理」判成了章节,章节「按键修饰符」判成了小节。
- 两段在原文里是
:::tip提示框的说明(「在 Mac 键盘上……」「请注意……」),判成了正文。 - 置信度普遍很低。 类型题的 confidence(
(n·peak − 1)/(n − 1),6 个选项)大多在 0.15–0.3 之间,换算回去,排第一的选项只有约 29%–42% 的概率。TypeSafe 原文里同样的题目大多在 0.9 以上。
渲染
代码根据判断拼出页面。连续的列表项合成一个列表,各项步骤概率的平均值 ≥ 0.5 时渲染成有序列表;连续的代码行合成一个代码块。
STEP_THRESHOLD = 0.5
HEADING_MARK = {"title": "#", "section": "##", "subsection": "###"}
CALLOUT_MARK = {"note": "NOTE", "tip": "TIP", "warning": "WARNING"}
def to_markdown(blocks: list[dict]) -> str:
groups = []
for b in blocks:
if b["type"] in ("list_item", "code") and groups and groups[-1][0] == b["type"] \
and not b["gap"]:
groups[-1][1].append(b)
else:
groups.append((b["type"], [b]))
parts = []
for kind, items in groups:
if kind == "list_item":
ordered = sum(b["step"] for b in items) / len(items) >= STEP_THRESHOLD
parts.append("\n".join(f"{n + 1}. {b['text']}" if ordered else f"- {b['text']}"
for n, b in enumerate(items)))
elif kind == "code":
parts.append("```\n" + "\n".join(b["text"] for b in items) + "\n```")
elif kind == "heading":
parts.append(f"{HEADING_MARK[items[0]['hlevel']]} {items[0]['text']}")
elif kind == "quote":
parts.append(f"> {items[0]['text']}")
elif kind == "callout":
parts.append(f"> [!{CALLOUT_MARK[items[0]['callout']]}]\n> {items[0]['text']}")
else:
parts.append(items[0]["text"])
return "\n\n".join(parts) + "\n"演示段落的输出(节选,完整输出在 results/walkthrough.md):
## 事件处理
### 按键修饰符
在监听键盘事件时,我们经常需要检查特定的按键。Vue 允许在v-on 或 @ 监听按键事件时添加按键修饰符。
```
<input @keyup.enter="submit" />
```
你可以直接使用 KeyboardEvent.key 暴露的按键名称作为修饰符,但需要转为 kebab-case 形式。
- 按键别名
- Vue 为一些常用的按键提供了别名:
- .enter
- .tab
- .delete (捕获“Delete”和“Backspace”两个按键)
### 系统按键修饰符
你可以使用以下系统按键修饰符来触发鼠标或键盘事件监听器,只有当按键被按下时才会触发。
- .ctrl
- .alt
- .shift
- .meta上面每一个字都来自输入,流水线只决定了边界、类型和标记。测试集 30 段的输出都通过了这项检查:去掉空白后,输出的字符序列与输入完全相同。
在 30 段测试集上的结果
评测方式:把输出和标准答案都去掉空白,逐字符比较它属于哪种块(字符级类型正确率),并统计完全还原的块(边界和类型都对)占标准块的比例。"细分"一列还要求标题层级、列表有序/无序、提示框种类也对。
| 方法 | 类型正确率(字符) | 细分正确率(字符) | 块完全还原 | 文字改动 |
|---|---|---|---|---|
| 象信两遍(本文流水线) | 72.4% | 71.2% | 63.7% | 无 |
| 规则:标点拼行 + 空行/长度/符号分类 | 86.0% | 81.9% | 62.9% | 无 |
| 象信拼行 + 规则分类 | 87.1% | 83.6% | 79.9% | 无 |
| 生成式大模型直接改写(DeepSeek) | 待补 | 待补 | 待补 | 待补 |
规则分类器很简单(main.py 里的 rule_blocks):不含汉字且带 {}<>;= 等符号的算代码;不超过 25 字、不以标点结尾、前后都有空行的算标题;前面没有空行的算列表项;以"注意""提示"开头的算提示框;其余算正文。它之所以强,是因为这份测试数据的空行本身就泄露了大量结构:块与块之间都有空行,只有列表项和代码行之间没有。真实世界里空行未必这么规整,规则的优势会打折扣;但在这份数据上,它就是比象信一号 1.0 的第二遍准。
把第二遍单独拿出来,在标准块上分类(排除第一遍的影响):块类型正确率 71.5%(488 块),标题层级在判对的 69 个标题里对了 42.0%,类型题的平均置信度 0.35。混淆最多的是:正文被判成代码(28 块)、正文被判成列表项(17 块)、列表项被判成正文(34 块)、标题被判成正文或列表项(16 + 13 块)。8 个提示框只认出 1 个。有序列表也没认出来:43 段里 58 组列表中有 9 组有序,它们的平均步骤概率最高只有 0.395,全都低于 0.5,全部渲染成了无序列表。
讨论
为什么拼行有效。 "这行是不是接着上一行的半句话写的"是一个局部的、几乎客观的判断,答案就在两行的交界处。把两行原文直接写进问题后,象信一号只需要看这两行,这正是它擅长的系统一式单步判断。规则只能看标点,而中文列表项、标题通常不带标点,所以错在同一类地方。
为什么分类不行。 "这是标题、正文还是列表项"要看整体版式:前后有没有空行、周围是不是一串并列的短行、它在文档里处于什么位置。象信一号 1.0 是 9B 的模型,训练数据以较短的状态、语义判断为主(见已知短板),这种依赖版式和位置的判断正是它的弱项;它给出的概率也很平,说明它自己也没把握。概率低这件事本身是有用的:它如实反映了判断的难度,可以用来决定哪些块需要人看一眼。
实际怎么用。
- 断行拼接交给象信,分类先用规则;规则拿不准的(比如既短又没有空行隔开的行),再用象信的类型题做第二意见。在本文数据上,"象信拼行 + 规则分类"的块完全还原率是 79.9%,比两者单独用都高。
- 问题里带上原文,不要只写编号(附录)。
- 阈值要在自己的数据上重新选:TypeSafe 原文的 0.2 在这里太松。
- 如果你的文档里有序列表很重要,不要依赖步骤概率;看行首有没有"第一""首先"或数字,在代码里判断更可靠。
什么时候不该这么用。 输入本来就有可靠的标记(HTML、docx、带缩进的纯文本)时,直接解析标记;需要改写措辞、补全缺字时,这不是分类问题,用生成式模型。
完整代码
https://github.com/xiangxinai/xiangxin-cookbooks/tree/main/autoformat
prepare_data.py:从 Vue 中文文档生成去格式文本和标准答案(data/excerpts.json)pipeline.py:问题定义、合并、渲染、评测main.py:全部实验;select_criteria.py:块类型描述的写法比较;latency_probe.py:延迟复测llm_baseline.py:生成式大模型基线results/:summary.json、per_excerpt.csv、每段的渲染结果markdown/*.md与请求缓存
附录
成本与延迟
测试集 30 段,每段两次请求,共 60 次请求、309,808 个输入 token,按 ¥0.042 / 百万输入 token(输出免费)计 ¥0.0130,平均每段 10,327 个 token、约 ¥0.00043。平均每段第一遍 21.0 个问题、第二遍 48.9 个问题。
演示段落单独复测了三次(latency_probe.py,不走缓存,顺序发送):第一遍 27 个问题、4,542 个输入 token,网关耗时约 4.1 秒;第二遍 100 个问题、12,655 个输入 token,约 14.4 秒;三次之间相差不到 0.2 秒。这比 TypeSafe 原文的 0.8 秒慢得多。第二遍的输入里包括每道题引用的原文和各自的选项描述,问题越多、引文越长,耗时越长。测试期间有其他批量任务共用同一套推理服务(运行中出现过 429 限流重试),这组数字只代表那段时间的情况,不代表服务的延迟承诺。
拼行阈值从哪来
在开发集(13 段)上,对"悬空行后的门槛"和"句末标点后的门槛"各在 0.05–0.95 之间按 0.05 网格搜索,取行对准确率最高的一组;并列时取离 TypeSafe 原文 (0.2, 0.5) 最近的。结果是 (0.35, 0.5)。
两个门槛分开的道理和 TypeSafe 原文一样:上一行已经以句末标点结束时,下一行是续写的先验本来就低,门槛应该更高;上一行悬空时,门槛可以放低,免得把真正的续写漏掉。同样在开发集上选一个统一门槛,结果也是 0.35,但它在测试集上只有 90.1% 的准确率(62 处错),两个门槛是 93.5%(41 处错)。标点是代码能直接读出来的事实,先用它把行对分成两组,再分别比较概率,比让一个门槛兼顾两种情况好。
为什么问"句子中间"而不是"同一段落"
同样的请求形状,只把问题换成"这两行是否属于同一个段落?"(门槛同样在开发集上选,结果是 0.75 / 0.85):
| 问法 | 测试集行对准确率 | 错误数 |
|---|---|---|
| 是否从句子中间接着写 | 93.5% | 41 |
| 是否属于同一个段落 | 87.3% | 80 |
演示段落里,一串不带符号的列表项 .enter、.tab、.delete……在"同一段落"问法下拿到 0.64–0.80,在"句子中间"问法下只有 0.18–0.31。并列的列表项在宽泛意义上确实"属于同一段":挨在一起,主题相同。"同一段落"问的是主题有没有延续,"句子中间"问的是文本本身。判断要喂给阈值时,问题应该指向能直接决定结果的那个最窄的事实。
为什么问题里要带原文
最初的版本照搬 TypeSafe 原文:问题里只写编号("L014 行是否……"),选项键名用英文(heading、list_item……)。在同一份测试集上:
| 写法 | 拼行准确率 | 第二遍类型正确率(标准块) | 端到端类型正确率(字符) | 块完全还原 |
|---|---|---|---|---|
| 只写编号 + 英文键名 + 长描述 | 87.0% | 57.6% | 64.8% | 41.4% |
| 带原文 + 中文键名 + 长描述 | 93.5% | 66.0% | 68.4% | 57.8% |
| 带原文 + 中文键名 + 短描述(正式版) | 93.5% | 71.5% | 72.4% | 63.7% |
只写编号时,象信一号 1.0 很难在状态里准确找到"L014"指的是哪一行:演示段落里所有行的续写概率都挤在 0.21–0.36 之间,真断行和假断行几乎分不开;第二遍前三个块(文档标题、章节标题、第一段正文)全被判成了列表项。把要判断的内容直接放进问题,模型就不必先做一步"按编号查找"。这也符合已知短板里"减少推理跳数"的建议。
我们还试过把类型题和附带题拆成两个并发请求(仍是一轮往返),看附带题会不会干扰类型题:在标准块上类型正确率 65.8%,与合并在一个请求里的 66.0% 几乎相同(results/summary_v1_long_criteria.json,长描述版本)。附带题可以放心和主问题一起问。
选项描述的写法
块类型的选项描述在开发集上比较了三种写法(select_criteria.py,只问类型题,标准块,13 段共 197 块):
| 写法 | 开发集类型正确率 |
|---|---|
| 长描述(照 TypeSafe 原文逐条翻译,带破折号和举例) | 66.0% |
| 短描述(正式版) | 76.1% |
| 只有键名,描述留空 | 72.6% |
长描述里"一个功能、一项任务、一个选项、一个术语"这类举例,反而把很多短标题和说明句吸到了"列表项"里。描述要写清边界,但不必把可能的样子都列一遍。
置信度最低的块
演示段落里类型置信度最低的块是小节标题「系统按键修饰符」:confidence 0.15,标题 0.29、列表项 0.25、正文 0.20。它确实判对了,但判对的把握和判错差不多。在界面上可以把类型置信度低于某个值的块标出来请人确认;不过在本文数据上,类型题的置信度整体偏低,这个门槛需要在你自己的数据上校准,不能照搬 TypeSafe 原文的 0.55。
生成式大模型基线(待补)
llm_baseline.py 让 DeepSeek(OpenAI 兼容接口)直接把测试集的 30 段纯文本改写成 Markdown,要求"不得改动任何文字",然后用同样的指标评测,并额外统计输出与输入的文字是否逐字一致。运行这一基线所需的密钥尚未就绪,上表中的"待补"会在运行后用 results/llm_baseline.json 的真实结果补上:
python llm_baseline.py # 需要 LLM_BASE_URL、LLM_API_KEY、LLM_MODEL费用将按 usage 与 DeepSeek 官方价目估算:输入 token × 输入单价 + 输出 token × 输出单价。

