ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

JSONL语料如何用vLLM批量生成Wiki风格Markdown文档

JSONL语料如何用vLLM批量生成Wiki风格Markdown文档

1. 从零开始:如何把零散语料批量变成结构化Wiki文档

最近在做一个知识库扩容的活儿,核心任务是把手里一大坨非结构化的对话记录、操作纪要转成结构清晰的百科式文档。数据本身是JSONL格式,几万行的体量,每一行都是长短不一的自然语言片段。手写转文档肯定不现实,找外包标注成本又太高,于是就有了这个项目——generate_wiki_md_from_jsonl_vllm。

这个项目做的事情很简单:从JSONL格式的原始语料出发,先做字段清洗和分段处理,再用vLLM做离线批量推理,逐条生成维基风格的Markdown文本,最后落盘成结构化的.md文档。整个链路是可以断点续跑的,不会因为某几行数据格式异常就推倒重来。

如果你手里刚好有一堆对话日志、客服记录、操作手册草稿,或者是在做RAG知识库的前置语料清洗,需要把这些内容快速整理成条目清晰的文档,这个方案能帮你跑通整条生产链路。下面我把完整的思路、代码骨架和踩过的坑一次说清楚。

1.1 为什么不用逐条调API,而是选vLLM离线批处理

最直觉的做法是一条条调用在线大模型接口,循环处理每行JSONL,拿到结果就写文件。数据量小的时候没问题,一旦来到万级行、十万级token的体量,这种方案的短板就非常明显:网络往返占大头,并发控制写起来麻烦,而且中间任何一条请求超时都要自己处理重试逻辑。

vLLM在这类场景里的价值在于把“逐条调用”变成“批量吞吐”。它内部的Continuous Batching机制会把多条请求拼在一个batch里推进,显存利用率明显比逐条串行高。我在测试环境对比过,同样100条数据、同样模型,vLLM离线批处理的总耗时大约是串行请求的四分之一到五分之一,而且不需要自己管并发。离线推理的API使用起来也很直接,把prompt列表一次性传进去,它会返回等长的输出列表,这个特性非常契合按行处理JSONL的需求。

另外,用本地推理服务还有个隐形优势:数据不用离开内网。对于很多语料带敏感属性的场景,能把处理过程全部放在本地或内网集群,本身就是一个比“上传到云端处理”更稳妥的选择。

1.2 项目到底解决了什么问题

如果只从代码规模看,这个项目不大,核心就是一个脚本加一个prompt模板。但它的定位是一条完整的数据处理流水线的一部分,自动化程度决定了它能复用到多少场景。

先说输入侧。JSONL文件每一行都是一个JSON对象,但实际语料远没有标准生产数据那么规矩,常见的情况包括:

  • 字段名不统一(有的行叫content,有的行叫text,有的行叫body)
  • 同一行的文本内容里混着标签、时间戳、乱码字符
  • 存在空行、重复行、只有字段没有值的“空壳记录”

脚本里最先要处理的就是这些脏数据。要有一个预清洗环节,把字段归一化、过滤无效行、去重,输出一个干净的JSONL中间文件。这一步虽然技术含量不高,但决定了下游生成质量的上限。

再说生成侧。给模型一条raw文本,要求它输出“维基风格的Markdown文档”,这本身是个很有挑战的指令。模型的输出很容易跑偏,比如直接复述原文、格式混用、无中生有地添加事实。要约束它,必须靠结构化的prompt模板加上few-shot示例,这两部分我花了不少时间调试,后面会详细展开。

2. JSONL数据准备与清洗细节

2.1 先搞懂JSONL的数据长什么样

JSONL(JSON Lines)每一行是一个独立的JSON对象,行与行之间没有分隔符。它比大型JSON数组好在可以逐行读取,不占内存,也方便流式处理,所以日志类、语料类的数据经常用这个格式。

举个例子,一行日志型数据可能是这样:

{"id": 10001, "source": "conversation", "text": "用户咨询如何配置网关,技术支持工程师建议修改路由表...", "timestamp": 1690000000}

而另外一批数据可能是这样:

{"dialogue_id": "a12", "utterances": ["你好,我想了解一下退款流程", "好的,请提供订单号..."]}

同一个文件里字段不统一是最常见的坑。因为不同来源的数据可能来自不同系统,导出脚本不同,字段名自然五花八门。清洗的第一步就是建立一套“标准字段映射”,把所有别名统一映射到一个内部字段上。

提示:清洗脚本中我把所有可用的文本字段按优先级拼接,比如优先取text,其次取content,再次取utterances(数组合并),最后兜底取body。这样不会漏数据,同时把id、source这类元信息保留在独立字段里,方便后续追溯。

2.2 清洗过程中必须处理的几个问题

问题一:文本长度极不均匀。有的行只有十几个字,有的行能有两三千字。过短的文本单独生成文档没有意义,我设了个最短长度阈值(比如少于30个字符的直接跳过);过长的文本则需要分段。分段策略不要按字数硬切,最好按语义边界,比如标点符号、换行符、对话轮次切换的位置来切。硬切会把一个完整的事件拆成前后两段,两段分别生成文档时,事实一致性很难保证。

问题二:重复内容多。语料里经常出现完全重复的行或者高度相似的段落(比如系统自动推送的通知)。清洗时我用了一个轻量去重方案:对文本做规范化(去掉多余空白、统一大小写),然后计算哈希,完全重复的直接去掉;高度相似的,用简单的编辑距离阈值过滤。这一层能帮推理侧省钱,少处理几千条重复数据。

问题三:不可见字符和编码问题。JSONL文件可能是从不同系统导出来的,编码可能是UTF-8也可能是带BOM的UTF-8,甚至部分行里面混着全角空格、特殊控制字符。清洗步骤里统一做一次strip,全角转半角,连续空白压缩成单个空格,控制字符直接移除。这些看起来不起眼的处理,真的会在生成质量上体现出来。

清洗完的数据建议写成一个cleaned.jsonl,结构如下:

{"id": "10001", "meta": {"source": "conversation"}, "content": "用户咨询如何配置网关,技术支持工程师建议修改路由表..."}

这个中间格式同时保留了原始元信息和标准化后的正文,下游prompt构造时直接从这里读取就够了。

3. 提示词设计与生成策略

3.1 维基风格Markdown到底要求什么

在写prompt之前,得先定义清楚“维基风格Markdown”具体交给模型什么形态的输出。我自己的定义是:

  • 必须有标题层级,文章有一个主标题,下面按逻辑分成若干章节
  • 章节标题尽量是名词性短语,比如“功能概述”“安装步骤”“常见问题”,而不是一句话
  • 内容以说明性文字为主,中性、客观,尽量不保留对话双方的人称
  • 适当使用列表、表格、代码块来组织信息
  • 长度控制在一个合理的范围,不能一段话就结束

模型本身并不是不懂这些要求,而是因为没有约束时,它会偷懒,倾向于复述原文而不是重新组织。所以prompt的核心任务是“强制”它做组织整理,而不是做摘要或改写。

3.2 一套可复用的prompt模板

实际跑下来,我用了这么一套结构:角色设定 + 任务说明 + 输出格式要求 + 强约束规则 + few-shot示例。具体模板如下:

你是一名资深技术文档编辑,擅长把零散的非结构化内容整理成结构清晰、表述客观的百科条目。 请根据下面提供的原始素材,整理出一份维基风格的Markdown文档。 要求: 1. 文档必须包含以下结构的Markdown标题:# 主标题、## 一级章节、### 二级章节 2. 内容要重新组织,不能简单复述原文,不要出现对话人称 3. 信息不足时,可以在正文中用"有待补充"标注,不要凭空编造细节 4. 尽量使用列表、表格、步骤说明等方式呈现内容 5. 全文控制在500-800字以内 原始素材: {input_text} 请直接输出Markdown文档。

这个模板有几处关键设计。

“不能简单复述原文”是为了抑制模型的偷懒行为。我在测试中发现,如果不加这句,很多输出就是把原文语序微调一下,换几个词就交差了。

“信息不足时标注有待补充”是为了防幻觉。维基风格要求客观中立,模型为了满足“文档完整”的隐含要求,很容易在信息不充分时自作主张地补细节,这是最危险的行为。加了这句之后,模型会更谨慎,不知道的地方会主动留空。

“控制在500-800字”是一个经验值。太短说明模型没有展开,太长则说明它在灌水。实际因为输入长度差异很大,后来我把字数要求改成了按输入长度的比例,比如“输出长度约为原文的60%到80%”,效果更稳定。

3.3 few-shot示例的用法与布局

模板里如果没有示例,模型输出的稳定性会很差。我加了三个示例,分别对应三种常见输入形态:

  • 示例一:一段客服对话记录 → 结构化FAQ文档
  • 示例二:一段操作流水日志 → 步骤说明文档
  • 示例三:一段零散的产品描述 → 百科条目文档

每个示例都完整标注了输入原文和期望输出的Markdown文档。few-shot示例占用的token不少,但对格式稳定性的提升非常明显。在专业模型上,示例能够隐式地告诉模型“Markdown标题要这样用”“列表要这样排”“人物对话要这样转化”。这个收益远大于为每个示例多花的几百个token的成本。

注意:示例里的内容要和真实场景相关。如果你的语料主要是技术问答,就不要放购物场景的示例,领域错位会让模型不知道该按哪个风格走。

3.4 关键生成参数怎么调

vLLM的SamplingParams里有几个参数值得反复调试。我最终使用的配置如下:

from vllm import SamplingParams sampling_params = SamplingParams( temperature=0.2, top_p=0.9, max_tokens=2048, repetition_penalty=1.05, stop=["<|endoftext|>"] )

温度设在0.2是经过好几轮测试后的选择。维基风格文档讲究准确、稳定,不需要太多创造性输出,温度太高会给“幻觉”可乘之机。实测温度从0.2调到0.8之后,编造细节的案例数量明显上升,格式也开始出现变化,所以这个场景下不建议用高温度。

max_tokens设2048是因为生成结果如果超过这个长度,后半段会被截断,而截断的Markdown文档经常只有半个表格或者残缺代码块。长度限制宁可设大一点,让模型在没写完时自然收尾。

repetition_penalty的1.05是一个很微妙的值。太低会让模型在长文本里重复句式,太高则会扭曲它正常的表达习惯,导致用词生硬。如果你跑出来的文档里有大量重复的段落开头,优先检查这个参数。

3.5 面对长文本的分块处理策略

输入超过模型上下文窗口是绕不开的问题。原始语料里有不少长对话记录,动辄几千字,不能整段塞进去。

分块策略采用“章节感知切分”,逻辑如下:

  1. 遍历原始文本,优先按\n\n切分,把文本块尽量控制在1500字以内
  2. 如果按段落切分后还有超大块,再按句号、问号、感叹号等边界切分
  3. 相邻的、语义上属于同一主题的段落,优先合并到同一块

每个文本块独立生成一个Markdown文档片段,最后按顺序拼接。这里有一个细节值得注意:拼接后会出现多个“主标题”,也就是每个块生成的文档都有一个#标题。处理办法是在合并时丢弃第二块及以后的主标题,只保留其下一级内容,同时在前面的主标题下方插入一个“更多内容”的索引段落。

4. 核心脚本实现:从JSONL到Markdown的完整链路

4.1 整体流程架构

脚本的整体流程可以画成一条类似流水线的结构,我在实现时分成了四个模块:预处理、批处理生成、响应解析、落盘合并。职责分离的好处是任何一个环节出问题,可以单独重跑,不用从零开始。

cleaned.jsonl --> prompt_builder --> vllm_offline_batch --> output.jsonl --> md_merger --> final.md

4.2 预处理模块核心代码

这里展示第一个模块的核心逻辑:

import json import re import hashlib def normalize_text(text: str) -> str: # 全角转半角、压缩空白、移除控制字符 text = text.replace("\u3000", " ").replace("\ufeff", "") text = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f]", "", text) text = re.sub(r"\s+", " ", text) return text.strip() def clean_jsonl(input_path: str, output_path: str, min_len: int = 30): seen = set() out_f = open(output_path, "w", encoding="utf-8") with open(input_path, "r", encoding="utf-8") as f: for line_no, line in enumerate(f, 1): try: obj = json.loads(line) except json.JSONDecodeError: # 记录坏行号,方便溯源 print(f"[warn] line {line_no}: JSON parse failed, skipped") continue content = (obj.get("text") or obj.get("content") or obj.get("body") or " ".join(obj.get("utterances", []))) if not content or len(content) < min_len: continue content = normalize_text(content) digest = hashlib.md5(content.encode()).hexdigest() if digest in seen: continue seen.add(digest) record = {"id": obj.get("id", str(line_no)), "meta": {"source": obj.get("source", "unknown")}, "content": content} out_f.write(json.dumps(record, ensure_ascii=False) + "\n") out_f.close() print(f"done, output written to {output_path}")

很多数据清洗问题都能在这个环节拦截住。比如JSON解析失败这一条,看起来很简单,但实际跑万行文件时经常能碰到几十行坏数据,如果不打印行号,后面排查会非常头痛。

4.3 调用vLLM离线批处理的代码骨架

vLLM离线批处理的核心调用逻辑很简洁,代码如下:

from vllm import LLM, SamplingParams def build_prompt(record: dict) -> str: # 拼上系统级instruction,与前面的prompt模板拼装 template = open("prompt_template.txt", encoding="utf-8").read() return template.replace("{input_text}", record["content"]) def batch_generate(records, model_name="Qwen/Qwen2.5-7B-Instruct", max_input_len=8192): llm = LLM(model=model_name, max_model_len=max_input_len) prompts = [build_prompt(r) for r in records] sampling_params = SamplingParams(temperature=0.2, top_p=0.9, max_tokens=2048, repetition_penalty=1.05) outputs = llm.generate(prompts, sampling_params) results = [] for record, output in zip(records, outputs): generated = output.outputs[0].text results.append({ "id": record["id"], "meta": record.get("meta", {}), "input_content": record["content"], "generated_md": generated }) return results

初看这段代码很简单,但实际上vLLM在模型加载阶段耗时很长,如果每条数据单独调用一次LLM(),大部分时间都浪费在了模型初始化上。这个脚本设计成一次性加载模型、传入所有prompt、最后统一回收结果,这才是离线批处理的正确用法。

如果你手里的机器显存不够大,模型加载后就已经吃了大部分显存,batch_size过大会导致OOM。vLLM虽然有Continuous Batching的优化,但它不会无限度地容下所有请求。实际跑的时候需要控制一次传入的prompt批大小,比如每次传200条生成,循环处理完所有数据。

4.4 响应解析与容错处理

vLLM返回的generated文本有时并不完全是干净的Markdown——开头可能会多几句“好的,这是整理后的文档:”之类的赘语,结尾偶尔会有半截没写完的句子。响应解析模块要处理这些问题:

def clean_generated(text: str) -> str: # 去掉前缀碎语 for policy in ["好的", "当然", "根据要求", "以下", "根据提供的"]: idx = text.find(policy) if idx != -1 and idx < 10: text = text[idx + len(policy):] break # 去掉开头和结尾的多余空行 text = text.strip() # 避免半截结尾,截断到最后一个可见的"# "标题 lines = text.splitlines() # 如果最后一行很可能是未完成段落(缺少结尾标点),丢弃它 if lines and lines[-1].rstrip() and not re.search(r"[。!?.!?`]$", lines[-1].strip()): # 保守做法:不丢掉,因为可能是列表项 pass return text

“保留或丢弃最后一行”我纠结了很久,最后的结论是:不要轻易丢。因为生成结果里最后一行经常是列表项或代码块结尾,虽然缺少句号,但截断造成的信息损失远大于格式瑕疵。如果只是篇幅超限被截断,那么前文该有的内容已经有了,后补的代价又太高,所以保留不完整的尾部比强行删掉更合理。

生成的整个结果写成一个generated.jsonl,每行结构包含id、input_content、generated_md,这样即使后面发现某个文档生成质量不合格,也可以定位到原始输入重新生成,不需要再读原始文件做二次解析。

4.5 合并生成最终的Wiki Markdown文件

合并模块做的事情是把分散的结果串成一个完整文件。核心逻辑如下:

def merge_to_wiki(results, output_md_path: str, top_title: str = "知识库总览"): out_lines = [f"# {top_title}", ""] for idx, item in enumerate(results, 1): md = item["generated_md"].strip() lines = md.splitlines() # 处理每个片段中的主标题,统一降级并追加序号 if lines and lines[0].startswith("# "): lines[0] = f"## {idx}. {lines[0][2:]}" merged = "\n".join(lines) out_lines.append(merged) out_lines.append("") with open(output_md_path, "w", encoding="utf-8") as f: f.write("\n".join(out_lines))

这里有个小细节:如果每片生成的文档都有#主标题,直接拼接会制造出多个顶级标题。代码里把第一条之后的所有主标题降为二级标题,是整个合并环节里最容易忽略的问题。

5. 实操参数选择与运行配置

5.1 模型选择:拿捏质量与资源的平衡

整个实验我试过3B、7B、14B三个量级的模型。3B模型生成速度快,显存占用小,但格式稳定性不太够,经常出现该换行不换行、列表符号混用的问题。7B模型在格式和内容质量上有质的提升,是性价比最高的档位。14B模型内容逻辑更顺,但在任务相对机械的场景里,质量的提升并不足以弥补推理耗时翻倍的代价。

最终选型建议:如果是10万行以下的中等规模语料、对质量要求不极端的场景,7B模型完全够用;如果语料领域非常垂直,且下游对结构化程度要求极高,可以考虑14B档位。

5.2 批大小的设置策略

vLLM的batch_size不是一个显式的参数,而是通过传入的prompt列表长度隐式控制。一次传入所有数据并不总是最好的选择。实践下来,我按5000字左右的总输入一个批次来切,既照顾了显存占用,也保证了批内的动态规划不会失控。如果机器是单卡24G显存,跑7B模型加4K上下文,一次传100到300条数据比较稳。

5.3 断点续跑与幂等性

生成流程跑几个小时很常见,但过程中难免有机器重启、显存报错、手动中断的情况。断点续跑的设计非常关键,我的做法是:

  • 每一批数据生成完立即落盘到generated.jsonl,而不是全部完成后再一次性写
  • 每一批数据使用独立的batch_xxx.jsonl文件名,在最后校验阶段再合并
  • 重启时自动检查哪些id已经有结果存在,跳过已经生成的记录

用独立的输入记录id来追踪进度,这样即使中断,重新启动脚本也可以精确地从断点继续,不需要重新处理已完成的批次。

6. 常见问题与排查技巧

6.1 输入数据引起的生成问题

症状一:某些文档生成得莫名其妙。

排查思路:优先看那条记录清洗后的content字段是否太短。如果输入本身就只有一句话“不能退款”,任何prompt也变不出花来。处理办法是提高最短长度阈值,同时在生成前对每条content做一个长度检查,不足阈值时直接跳。

症状二:长文本被截断,结尾明显不完整。

排查思路:检查这条记录的输入长度是否超过了模型的最大上下文窗口。vLLM对超长输入的处理是抛异常,不会静默截断。如果你的输入长度在窗口内,但生成长度超了max_tokens,那应该调大max_tokens。如果已经是2048还是截断,建议削减单条输入长度,而不是继续加大max_tokens——在超长输入的场景下,模型的中段注意力会退化,后半段内容质量明显下降。

6.2 输出格式相关问题

症状三:模型在输出中保留了对话口吻,甚至出现“客服说”“用户问”这样的表达。

解决方案:把prompt里的“不要出现对话人称”改成“用第三人称转述所有对话内容”,并且补一条few-shot示例。模型在few-shot里看到“这段对话被转写为客观的问答条目”后,行为会明显改善。

症状四:输出的Markdown里出现不存在的表格。

表格是维基文档里非常有表达力的格式,但也是幻觉重灾区。模型为了填满表格,会自行编造数据。这里我加了硬性规则:“如果不确定表格数据,不要使用表格。”实测加了这个约束后,表格类的编造大幅减少。

6.3 资源与速度问题

症状五:多卡环境下显存分配不均,单卡OOM。

vLLM会自动做张量并行,但只在你显式设置tensor_parallel_size时才会多卡并行。如果不开,默认单卡跑,小显存机器容易OOM;开太大的tensor_parallel_size又会因为通信开销拖慢整体速度。具体的值要看你的模型大小和卡间带宽,需要实测几组数值对比。

症状六:生成速度不稳定,忽快忽慢。

能导致这个现象的原因很多,最常见的是同一时间还有其他任务在占用GPU计算。另外,batch里的prompt长度差异极大时,连续批处理的表现也会波动。我建议在分批时按输入长度做一次排序,短文本批次放前面,长文本批次放后面,这样每批内的计算负载更均匀,整体吞吐更稳定。

6.4 内容质量问题的终极兜底

模型不是每次都能一次生成合格文档的,有时候输出格式完全错乱,或者内容大量偏离原始素材。对这种情况我的兜底方案是:

  1. 解析生成的Markdown结构,统计标题数量、段落数量、列表数量
  2. 低于阈值(比如标题数为0、列表数为0)的记录标记为low_quality
  3. 对low_quality记录,换更高温度再重试一次
  4. 重试后仍然不合格的,输出到一个单独的failed.txt,人工处理或者保留原始素材等待下游其他方案

这个兜底机制虽然不能保证每一个文档都堪称完美,但能确保进入最终合并文件中的内容至少通过一次机器质检,而不是像个黑盒一样让不合格内容混入知识库。

7. 我的一点实操体会

这套流程跑通之后,最明显的收益不是生成了多少份文档,而是建立起了一条从“原始非结构化语料”到“可检索结构化文档”的全自动管线。

我一个很深的体会是:提示词工程对这个项目的贡献比代码结构更大。代码逻辑不难,无非是读JSONL、调接口、写文件、拼接字符串,真正决定产出质量的,是prompt模板里那几句约束和三个few-shot示例。很多人觉得调API生成文档是“随手一调”的事,实际跑过之后才知道,约束模型的重组行为比让它自由发挥要难得多。好的模板应该把模型的发挥空间严格限制在“重新组织已有内容”上,而不是“自由生成新内容”。

另一个体会是:数据清洗花的时间占比比我预想的高得多。一批真实的语料,永远有你没想到的脏数据形态,可能是编码问题、字段缺失、语义重复。清洗做得越透,下游生成越省心。如果清洗环节偷懒,模型会把数据里的噪声“忠实”地转述出来,生成结果的质量上限被输入质量锁死,prompt再好也很难救回来。

还有一个建议:不要把生成结果直接当最终答案。机器生成的文档适合做初稿和素材,投入知识库前最好有一个人工抽检环节。我跑完一轮后,从生成结果里随机抽了100篇检查,准确率大概在85%左右,大致能直接用的程度。剩下15%的问题主要是细节事实错误、语气不一致、部分内容缺失,这些是任何生成式方案都难以完全避免的。把它定位成一个“高效初稿生成器”,人工只做筛选和少量修正,整体的效率提升已经非常可观了。

如果你手头也有类似的非结构化语料需要整理成文档,可以按这套思路搭一条自己的流水线。模型和参数都可以换,但“先清洗、再分批生成、后合并校验”的核心链路是通用的。把这条链路跑顺了,换个数据源、换个输出格式,都只是改改模板和字段映射的事儿。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进