Llama 3解析Markdown失败?用Python搞定版本记录提取
2025-04-01 17:02:49
搞定 Markdown 解析:Llama 3 不给力?试试这些招儿
你是不是也遇到了这样的情况:想用 Llama 3 这种强大的语言模型来帮你解析 Markdown 文件,比如从一堆版本迁移记录里提取信息,再整理成一个漂亮的表格。想法很美好,写了个 Python 脚本,用了 Ollama 库,还特意定制了个 Llama 3 模型,把上下文窗口 (num_ctx
) 拉到 32768,觉得万无一失了。
结果呢?处理小猫两三条记录还行,虽然慢得像蜗牛爬(等了快 50 分钟),但好歹有结果。可一旦把整个 migrations.md
文件扔进去,Llama 3 就开始“胡言乱语”,给了一段不知所云的 JSON 片段,还反问你“想用这些数据干嘛?” 这离我们想要的那个整齐的 Markdown 表格,差了十万八千里。
# 这是你的 Python 代码,尝试用 Llama 3 解析 Markdown
import ollama
import re
prompt = re.sub(r"\s+", " ", f"""
# ... (此处省略了你超长、超复杂的提示词) ...
Please sort the entire output table based on the values in the From PWA version column.
Response only with the result table, please.
Here is the input data:
""".strip())
with open('migrations.md') as f:
migrations_description = f.read()
prompt = prompt + migrations_description
def main(prompt_text):
print("Please wait ...")
response = ollama.chat(model='intershop-pwa-changelog-converter',
messages=[{'role': 'user', 'content': prompt_text}])
with open('response.md', 'w') as file:
file.write(response['message']['content'])
print("All done!")
if __name__ == '__main__':
main(prompt)
还有你的 Modelfile:
FROM llama3
PARAMETER temperature 1
PARAMETER num_ctx 32768
碰上这种事儿,确实挺让人头疼的。别急,咱们来分析分析,看看问题到底出在哪儿,再找找靠谱的解决办法。
一、为啥 Llama 3 会“翻车”?
直接把这么复杂的任务甩给 Llama 3,效果不理想,其实不完全是 Llama 3 的锅。这里面有几个原因:
-
任务太复杂,LLM 扛不住:
看看你的提示词(Prompt),里面塞了多少活儿?- 根据
## From [版本号] to [版本号]
模式 查找和提取 内容。 - 对提取的内容进行 文本处理 (替换时态)。
- 在内容里 查找特定信息 (Angular/Node/NPM 版本),找不到还得用 上一行 的或者 默认值 。这涉及到跨记录的上下文依赖和条件逻辑。
- 最后,把所有信息整合成一个 特定格式的 Markdown 表格 ,还得包含 硬编码的值 。
- 完了还得 排序 !
这么多步骤,每一步都有可能出错。语言模型处理这种高度结构化、多步骤、带条件逻辑的任务,本身就不是强项。它更擅长理解和生成自然语言,而不是像程序一样精确执行一长串指令。你等于是在让一个诗人去精确计算火箭轨道,有点强人所难了。
- 根据
-
上下文窗口 (Context Window) 的“陷阱”:
你把num_ctx
设置到了 32768,看起来很大。但对于模型来说,在这么长的文本里,同时记住并精确执行你那一长串复杂的指令,还要处理那么多的 Markdown 细节,是非常困难的。模型的“注意力”是有限的,处理长序列和复杂指令时,很容易“忘记”前面的要求,或者搞混不同步骤的逻辑。它可能抓住了你输入数据的主要内容(Angular 2 迁移),但无法完美执行所有细节操作。那段奇怪的 JSON 输出,就是它“尽力了”但没成功的证明。 -
提示工程 (Prompt Engineering) 的挑战:
你写的 Prompt 非常详细,意图是好的。但是,对于 LLM 来说,这么长的、包含大量特殊指令(比如[resulted content]
这种占位符)的 Prompt,可能反而会增加理解难度。模型可能会误解某些指令,或者无法将所有指令正确地关联起来。而且,temperature
设置为 1,鼓励了模型的“创造性”,这对于需要精确输出的任务来说,可能不是好事,更容易产生意想不到的结果。 -
LLM 的不确定性:
语言模型本质上是概率性的。即使是同样的输入,每次运行的结果也可能不完全一样(尤其当temperature
较高时)。对于需要稳定、精确结果的数据处理任务,这种不确定性是个大问题。而你的需求,恰恰是需要高度精确和格式化的输出。
简单说,你让 Llama 3 干了一件更适合用传统编程方法来解决的活儿。
二、换个思路:靠谱的解决方案
既然直接让 Llama 3 包揽全局不太行,咱们就得换个策略。核心思想是:让专业的工具干专业的事 。代码擅长精确的模式匹配、逻辑判断和数据处理,就让代码来;LLM 擅长理解和处理自然语言的细微之处,那就把它用在刀刃上(如果真的需要的话)。
方案一:代码为主,LLM 为辅(混合动力法)
这种方法是,主要的解析、提取、结构化工作交给 Python 代码,只在某些代码不好处理的“软”任务上(比如复杂的语义理解或文本改写)才调用 LLM。
原理和作用:
利用 Python 强大的字符串处理和正则表达式能力,先完成基础的结构化解析。比如,把 Markdown 文件按 ## From ... to ...
切分成独立的记录块,提取各个版本号。对于时态替换这种任务,如果规则比较固定(比如简单过去时 -> 将来时),正则表达式也能搞定。如果涉及到更复杂的自然语言理解,可以考虑把这部分 单独 交给 LLM 处理。
操作步骤:
-
Python 读取文件: 这个你已经做了。
-
切分记录: 使用正则表达式找到所有
## From ... to ...
的标题,把 Markdown 文件切分成多个部分。每个部分对应一个迁移版本。import re with open('migrations.md', 'r', encoding='utf-8') as f: content = f.read() # 使用正则表达式按 "## From ..." 分割文本 # 注意:(?=...) 是正向前瞻,确保分割点本身包含在下一段的开头 # re.DOTALL 让 '.' 可以匹配换行符 sections = re.split(r'(?=## From \d+(\.\d+)* to \d+(\.\d+)*)', content, flags=re.DOTALL) migration_blocks = [] for section in sections: if section.strip().startswith("## From"): migration_blocks.append(section.strip()) # print(f"找到了 {len(migration_blocks)} 个迁移记录块")
-
提取核心信息(Python + Regex): 遍历每个
migration_block
,用正则表达式提取 "From version", "To version",以及记录内容。extracted_data = [] for block in migration_blocks: from_to_match = re.match(r'## From (\S+) to (\S+)', block) if from_to_match: from_version = from_to_match.group(1) to_version = from_to_match.group(2) # 记录内容是标题行之后的所有内容 content_start_index = from_to_match.end() migration_content = block[content_start_index:].strip() extracted_data.append({ 'from': from_version, 'to': to_version, 'raw_content': migration_content, # 先把其他字段置空,后面再填充 'angular': None, 'nodejs': None, 'npm': None, 'processed_content': None }) # print(extracted_data[0] if extracted_data else "没有提取到数据")
-
提取依赖版本(Python + Regex + 逻辑): 这一步稍微复杂点,需要处理“找不到就用上一行或默认值”的逻辑。
default_angular = "11" default_nodejs = "14.15.0 LTS" default_npm = "6.14.8" previous_angular = default_angular previous_nodejs = default_nodejs previous_npm = default_npm for item in extracted_data: content = item['raw_content'] # 尝试用正则查找版本 angular_match = re.search(r'Angular(?: version)?\s*(\d+(\.\d+)*)', content, re.IGNORECASE) nodejs_match = re.search(r'Node\.?js(?: version)?\s*([\d\.]+ LTS?)', content, re.IGNORECASE) npm_match = re.search(r'NPM(?: version)?\s*(\d+(\.\d+)*)', content, re.IGNORECASE) # 找不到就用上一条记录的,或者默认值 item['angular'] = angular_match.group(1) if angular_match else previous_angular item['nodejs'] = nodejs_match.group(1) if nodejs_match else previous_nodejs item['npm'] = npm_match.group(1) if npm_match else previous_npm # 更新 "previous" 值,供下一轮循环使用 previous_angular = item['angular'] previous_nodejs = item['nodejs'] previous_npm = item['npm'] # print(extracted_data[0] if extracted_data else "没有提取到数据")
-
处理文本内容(可选 LLM 或 Regex): 这一步是替换时态。
- Regex 方案(推荐): 如果时态替换规则明确,用
re.sub()
通常更稳定、更快。你需要定义一些匹配过去时态动词的模式,并替换成将来时态。这可能需要一些语言学知识或者多试几次正则模式。例如,简单的 "was" -> "will be", "updated" -> "will update"。这可能不完美,但对于技术文档通常够用。def simple_tense_change(text): # 这是一个非常简化的示例,实际需要更复杂的规则 text = re.sub(r'\bwas\b', 'will be', text) text = re.sub(r'\b(updated|migrated|removed|added)\b', r'will \1', text) # ... 可能需要更多规则 return text for item in extracted_data: item['processed_content'] = simple_tense_change(item['raw_content'])
- LLM 方案(备选): 如果你觉得 Regex 搞不定,或者文本非常口语化、时态复杂,可以考虑对每个
item['raw_content']
单独 调用 Llama 3,只给它这一个任务。但要小心: 这会让处理速度大大减慢,并且仍然有 LLM 不按要求输出的风险。你需要设计一个非常专注的 Prompt,比如:
安全建议: 如果调用外部 LLM API 而不是本地 Ollama,注意 API 密钥管理和成本。# (这是调用 LLM 的伪代码,需要用 ollama 库实现) # ollama_prompt = f"Please rewrite the following text by changing past simple and present perfect tenses to future simple tense. Respond only with the rewritten text:\n\n{item['raw_content']}" # response = call_ollama(ollama_prompt) # 你需要实现这个调用函数 # item['processed_content'] = response
- Regex 方案(推荐): 如果时态替换规则明确,用
-
格式化输出与排序(Python): 现在所有数据都在
extracted_data
这个列表里了,处理起来就很容易了。from packaging.version import parse as parse_version # 用于版本号排序 # 排序: 需要处理版本号字符串,建议用库来比较 # key 函数尝试解析版本号,失败则返回一个很小的版本对象以排在前面 def get_sort_key(item): try: # 处理可能存在的非数字版本,或者附加字符 version_str = re.match(r'(\d+(\.\d+)*)', item['from']).group(1) return parse_version(version_str) except: # 如果解析失败(例如版本号格式奇怪),给一个极小值 return parse_version("0.0.0") extracted_data.sort(key=get_sort_key) # 生成 Markdown 表格 markdown_table = "| From PWA version | To PWA version | Angular version | NodeJS version | NPM version | Things we need to do | Estimate | Regression |\n" markdown_table += "|---|---|---|---|---|---|---|---|\n" for item in extracted_data: # Markdown 表格内容里的换行符和 | 需要处理 content_escaped = item['processed_content'].replace('|', '\\|').replace('\n', '<br/>') markdown_table += f"| {item['from']} | {item['to']} | {item['angular']} | {item['nodejs']} | {item['npm']} | {content_escaped} | 1 | two days |\n" # print(markdown_table) with open('response_python.md', 'w', encoding='utf-8') as f: f.write(markdown_table) print("All done using Python script!")
进阶使用技巧:
- 可以考虑使用更健壮的 Markdown 解析库(如
mistune
或markdown-it-py
)来辅助解析 Markdown 结构,而不是完全依赖正则表达式,特别是在 Markdown 结构更复杂的情况下。 - 版本号比较使用
packaging.version
库更可靠,能处理各种复杂的版本号格式。
优点:
- 大部分处理由代码完成,结果稳定、可预测。
- 速度快得多,尤其是纯代码方案。
- 更容易调试和维护,因为逻辑都在代码里。
缺点:
- 需要编写和调试更多的 Python 代码。
- 如果真的需要 LLM 做某些复杂的文本处理,仍然需要调用 LLM,并处理其不确定性。
方案二:纯代码方案(硬核解析法)
这是方案一的彻底版本,完全放弃 LLM,所有工作都用 Python 和正则表达式完成。对于你的这个任务,这很可能是最合适、最高效的方法 。
原理和作用:
利用 Python 的内置功能和强大的 re
模块,实现对 Markdown 文本的完全控制解析和转换。所有规则和逻辑都显式地写在代码里。
操作步骤:
基本同方案一的步骤 1 到 4,加上步骤 5 的 Regex 方案 ,以及步骤 6。完全不需要 ollama
库和任何 LLM 调用。
代码示例:
参考方案一中各个步骤的 Python 代码片段,将它们整合到一个完整的脚本里。关键在于时态替换也用 re.sub()
实现 ,就像方案一里展示的那样。
优点:
- 最快、最稳定: 没有 LLM 调用的延迟和不确定性。
- 完全可控: 所有逻辑都在你的代码里,方便修改和测试。
- 资源消耗低: 不需要运行 Llama 3 模型。
- 零成本: 不需要支付 LLM API 费用(如果用的是云服务)。
缺点:
- 编写 Regex 可能费劲: 特别是时态替换的规则,可能需要反复调试才能覆盖大部分情况。
- 不适用于模糊或语义任务: 如果需求变为“总结迁移要点”或“判断迁移风险等级”,纯代码方案就比较难了,那时才需要考虑 LLM。
方案三:优化 LLM 提示(风险仍在,不太推荐)
如果你非要坚持用 LLM,可以尝试优化提示词和使用方法,但成功率可能依然不高,特别是对于大文件。
原理和作用:
尝试通过改进与 LLM 的交互方式,引导它更好地理解和执行任务。
操作步骤:
-
分解任务 (Prompt Chaining): 不要在一个 Prompt 里让 LLM 干所有事。可以试试:
- 第一步:只让 LLM 提取
## From ... to ...
块和基本版本号。 - 第二步:对每个提取块的内容,再发一个 Prompt 让 LLM 做时态转换。
- 第三步:再发一个 Prompt 让它提取依赖版本(处理上一行逻辑会非常难)。
- 第四步:最后整合信息生成表格。
缺点: 非常慢,多次调用增加了出错概率和成本。
- 第一步:只让 LLM 提取
-
少样本学习 (Few-Shot Learning): 在你的 Prompt 里,开头给出 1-2 个完整的例子。包括输入 Markdown 片段和对应的完整输出 Markdown 表格行。这能给模型一个更具体的模仿样本。
Here are some examples: Input section: ## From 0.25 to 0.26 - We updated Angular to version 10. - NodeJS was 12.x. Output table row: | 0.25 | 0.26 | 10 | 12.x | 6.14.8 | - We will update Angular to version 10.<br/>- NodeJS will be 12.x. | 1 | two days | --- Now, process the following input data based on the rules and examples: [你的 migrations.md 内容]
-
简化指令和输出格式:
- 尝试让 LLM 先输出 JSON 格式,每条记录一个 JSON 对象。这样结构化程度更高,方便你用代码后续处理成 Markdown 表格。但从你的错误输出来看,它好像尝试过但失败了。
- 减少指令数量,比如去掉时态转换,或者去掉复杂的版本依赖逻辑,看能否让核心提取任务成功。
-
调整模型参数:
- 降低
temperature
,比如设置为0.5
或0.7
,减少随机性,让输出更贴近指令。 - 检查 Ollama 是否还有其他可以调整的参数,比如限制输出 token 数量(虽然这里你希望它完整输出)。
- 降低
进阶使用技巧:
- 某些高级 LLM 平台支持“函数调用 (Function Calling)”或“工具使用 (Tool Use)”,可以让 LLM 调用你定义的代码函数来执行特定任务(如 Regex 匹配)。如果 Ollama 或其支持的模型将来支持类似功能,会是混合方案的更优实现。
安全建议:
- 同方案一,注意 API 密钥和成本(如果不使用本地 Ollama)。
- 小心 LLM “幻觉”,它可能会编造信息或错误地执行指令,结果需要仔细检查。
但总的来说,对于这种规则明确、结构化的数据处理任务,强烈建议优先考虑方案一(混合)或方案二(纯代码),特别是方案二。
别灰心,Llama 3 是个强大的工具,但得用对地方。对于你目前这个 Markdown 解析和表格生成的任务,用 Python 代码来主导,甚至完全由代码搞定,会是更稳妥、更高效的选择。把 LLM 留给那些真正需要自然语言理解能力的硬骨头吧!