大模型通识 / 大模型应用开发入门
11. 读取自己的文件——保留文本、来源与版本
原文备份 · 第 13 / 19 篇 · 查看极客时间原知识库 ↗
本篇目录 · 8 节
来源:大模型应用开发入门
前几篇的笔记都写在 Python 代码里。你已经能把它们变成复习卡,也能让模型提出一项等待确认的复习安排。可是,自己的资料往往放在 Markdown 文件、读书笔记和课程摘录里。每增加一份资料就去修改代码,很快会变得麻烦。
把文件读进来并不难。更容易被忽略的问题是:一个月后再看这张卡,你还能确认它引用了哪个文件、当时哪一版文字吗? 文件可能改过,文件名可能相同,模型说“根据笔记”也不意味着程序已经留下依据。本篇把这些问题一起解决。
完成后,你可以指定一到三份小型文本或 Markdown 笔记,预览实际读取内容,再围绕一个问题生成卡片。每次生成都会留下来源快照。先用配套样例离线走通;真实调用是可选步骤。
下载本篇完整代码 · 代码说明 · 上一篇:工具调用
核实日期:2026年10月4日。示例使用 Python 3.11+、openai 3.24.0;离线验证环境为 Python 3.11.8。没有使用真实 API 密钥,没有发起付费请求。演示答案由作者预先编写,不能作为模型效果测试。
一、先决定“读取文件”具体做什么
假设你有两份笔记:rag.md 介绍 RAG 的流程,review.txt 提醒自己核对证据。我们希望回答:“笔记中的RAG流程是什么?”
程序会依次完成这些事:
你明确选定的文件
↓ 检查路径、大小、编码
规范化后的完整文本 + 来源信息
↓ 预览;真实运行时先保存快照
只把笔记编号、文本和问题放进模型请求
↓ 获取 JSON 答案
检查字段、资料编号、引用是否确实存在
↓
保留卡片与来源快照,人工核对引用是否支持结论
本篇读取文件的全部文本,还没有“按问题检索相关片段”这一步。它为下一篇的最小 RAG 准备资料入口,但不能因为模型看到了文件,就把这一版称为完整检索系统。
读取 Markdown 也有明确范围:# 标题、列表、代码围栏都按文字保留。程序不渲染网页、不执行代码,不访问其中的链接,不下载图片。PDF、Word、扫描件暂不支持;把 .pdf 改名成 .md 不会完成格式转换。
这次新增 source_files.py 管理读取,file_cards.py 管理文件卡片入口;复用第06篇以来的 JSON 请求与卡片校验。旧 main.py 聊天、cards.py 固定笔记卡片和 planner.py 复习计划入口仍保留,文件卡片不会自动给计划工具增加新权限。
二、准备项目,先看程序实际读到了什么
解压 ZIP,进入其中的 lesson-11 文件夹。它是完整独立快照,不需要把零散代码粘到上一课。目录中已经有 sample-notes/rag.md 和 sample-notes/review.txt。
macOS / Linux:
python3 -m venv .venv
.venv/bin/python -m pip install -r requirements.txt
cp config.example.json config.json
.venv/bin/python file_cards.py --inspect
Windows PowerShell:
py -3 -m venv .venv
.\.venv\Scripts\python.exe -m pip install -r requirements.txt
Copy-Item config.example.json config.json
.\.venv\Scripts\python.exe file_cards.py --inspect
已有 config.json 就保留,跳过复制。安装依赖需要网络,但不会请求模型;--inspect 本身不读密钥、不联网,也不需要配置文件。省略所有模式参数时,默认行为同样是检查,只有显式 --run 才会发送资料。
终端会打印一份 JSON,随后提示“仅本地读取;没有请求模型,也没有保存新记录”。先找 notes:应有 F1 和 F2。F1.text 包含 # RAG基础 以及流程说明;F2.text 包含核查引用的提醒。JSON 显示的 \n 是换行的表示方式,不是文件出现了乱码。
再找 sources:第一项应指向你电脑上的 rag.md,第二项指向 review.txt。对于原封不动的配套文件,两项的 line_end 分别为5和2;这里表示整份文本覆盖的行范围,不是答案引用已经精确定位到这些行。引用本身仍用原文片段定位。
此时没有新增 records 文件。若目录里已有旧记录,它们不会被删除。成功标准是“读到了自己选定的文字,来源路径正确”,不是“终端没有报错”。
三、编码与长度:为什么不直接忽略错误或截一半
文件在磁盘上是字节,模型请求里需要的是文字。编码规则决定如何把两者对应起来。本课只接受 UTF-8,也兼容 UTF-8 文件开头的 BOM 标记。Python 的 utf-8-sig 解码器会识别并跳过开头的这个标记;普通无 BOM 的 UTF-8 也能读取。Python 编码文档
代码中的关键处理如下,见 source_files.py:
try:
text = raw.decode("utf-8-sig")
except UnicodeDecodeError:
raise ValueError("文件不是有效UTF-8,请另存为UTF-8后再试。") from None
text = text.replace("\r\n", "\n").replace("\r", "\n")
最后一行把常见换行表示统一为 \n。它不会删掉标题、段落空行或行首空格,也不会顺便改写标点。后续引用要和处理后的文本比较,所以这些规则必须明确。
遇到无法按 UTF-8 解码的字节,例如常见的 GBK 中文文件,程序会停止,要求你在编辑器中另存为 UTF-8。程序不猜测原始编码;解码成功后也应预览文字是否符合原意。不要使用 errors="ignore" 把无法解码的字节偷偷跳过:假如被删掉的恰好是“不得”中的关键文字,读到的资料就变了,而你可能完全没有注意到。
我们还设置了四个教学限制:
| 检查位置 | 本课上限 | 超出后的行为 |
|---|---|---|
| 文件数量 | 1到3份 | 要求缩小选择范围 |
| 单文件原始大小 | 16384字节,即16 KiB | 停止,不截断 |
| 所有规范化文本合计 | 10000个 Python 字符 | 停止,不截断 |
| 整个消息列表序列化后 | 12000个字符 | 停止,不发送 |
中文字符通常占多个 UTF-8 字节,因此“16 KiB”和“一万字符”不是同一个计量单位。消息还包含任务说明和 JSON 转义,所以文本未到一万字符,也可能先触及最后一道限制。
这些数字是本课程为小项目选择的边界,不是 DeepSeek 服务限制,更不是精确 token 计数或费用承诺。聊天入口原有的2000字符检查仍保留;文件入口独立计算自己的消息预算,没有把聊天限制悄悄改大。
读取时最多拿到“单文件上限加一个字节”,足以判断是否超限,避免为了发现文件太大先把整个大文件装进内存。我们也不自动只取前半部分:文件后面可能恰好写着例外条件。资料太大,先选一份完整的小笔记;下一篇再学习怎样选取相关片段并保留出处。
四、F1、路径和两个哈希分别解决什么问题
F1 只是这一次请求里的资料编号。你先传 rag.md,它就是 F1;下次先传别的文件,F1 的含义就会变。因此不能拿着一个脱离请求的 F1 去查“全局笔记库”。
程序为每份资料留下以下对应关系:
| 字段 | 用途 | 不能说明什么 |
|---|---|---|
note_id |
把模型引用关联到本次资料 | 不代表跨运行固定身份 |
path / name |
告诉你当时从哪里读取 | 文件后来可能移动或被修改 |
raw_sha256 |
标记原始字节内容 | 不是资料可信度评分 |
text_sha256 |
标记规范化后送入任务的文本 | 不能证明模型理解正确 |
notes[].text |
保留本次实际使用的文本 | 不会自动保留以后每一次编辑 |
SHA-256 会把一段字节计算为固定长度的摘要,本课把它当作内容的“指纹”。Python 标准库提供相应实现。Python hashlib 文档
为什么留下两个?同样两行文字,从 Windows 换行改成另一种换行,原始字节会变,raw_sha256 也会变;经过本课规范化后文本可能完全一样,text_sha256 就仍相同。这样可以区分“磁盘文件表示变了”和“本次输入文字变了”。
哈希不是加密。旁边的快照就含有明文笔记;也不能靠哈希判断笔记是否真实、是否过时。它更不等于版本管理:本课只在生成卡片时留下一份输入快照,不监控文件编辑。
文件路径用 Path.resolve() 转为解析后的绝对路径,并处理符号链接。本次输入同一个解析后路径两次会被拒绝;这不等于按内容消除了所有重复文件。Python pathlib 文档
本地绝对路径有助于你找回资料,但不需要交给模型。本课只将 notes 和问题加入消息;路径、哈希和读取时间放在本地记录里。如果你自己在笔记正文里写了路径,那段文字仍属于正文,会随正文发送。
五、离线生成一张卡,核对五份记录
在项目目录运行:
.venv/bin/python file_cards.py --dry-run
.venv/bin/python file_cards.py --demo
Windows 将开头的 .venv/bin/python 换成 .\.venv\Scripts\python.exe,其他参数相同。
先看预演:请求里仍是 deepseek-flash、非流式 JSON 输出、关闭思考。本入口只做一次尝试,不启用自动重试;输出预算默认来自配置的1024。消息是独立的 system 和 user 两条,不混入之前聊天历史。当前 JSON 输出仍需要设置 response_format,提示里明确 JSON 格式并提供样例;也必须检查空内容和输出截断。DeepSeek JSON Output
再看演示:你应看到“规则检查通过:1 张卡。语义仍待人工审核。”完整运行后,同一个运行编号下新增五份文件:
| 文件名末尾 | 保存的内容 |
|---|---|
-sources.json |
来源信息,以及实际用于本次任务的规范化文本 |
-request.json |
本地实验信息与模型请求体,并指向来源快照文件 |
-attempt-01.json |
这一次模拟尝试的状态 |
-response.json |
作者构造的 SDK 形状响应 |
-result.json |
本地校验状态及通过规则的卡片 |
它们位于脚本旁的 records 中,文件名以 files-demo- 开头。每运行一次都会生成新编号,不覆盖上次。source_snapshot_file 在请求记录的外层;它只是本地文件关联信息,不会被加进 API 请求体。
请独立检查三组关系:
- 打开最新
-result.json,卡片引用的note_id应为F1。打开同编号-sources.json,确认F1确实对应rag.md。 - 把卡片的
quote与快照中F1.text对照。引用应该逐字存在;再用自己的理解判断它是否支持答案。 - 打开同编号
-request.json,检查request.messages中的笔记与来源快照一致,且没有自动加入本地来源路径。experiment.simulated应为true;模拟响应的usage是null,不是实测用量为零。
程序先保存来源快照和请求记录,再进入发送步骤。若这些记录保存失败,就不会继续请求模型。快照保存的是已经读入内存、构建请求的那份文本;运行途中原文件再被修改,不会让本次请求悄悄变成新版本。
但它没有备份原始字节,不能从规范化文本完整还原原来的 BOM 和换行形式。它也不是能抵御其他程序同时恶意修改文件的文件系统事务。本课需要保证的是:我们能核对应用本次实际使用的那份资料。
六、换成自己的笔记,再决定是否真实调用
先准备一份没有敏感信息的 UTF-8 小笔记。可以把它放在 sample-notes/my-note.md,在编辑器里写上几段内容,然后运行:
.venv/bin/python file_cards.py --files "sample-notes/my-note.md" --question "这份笔记建议如何检查回答?" --inspect
.venv/bin/python file_cards.py --files "sample-notes/my-note.md" --question "这份笔记建议如何检查回答?" --dry-run
--files 后可以连续写多个文件路径。路径有空格就加引号;相对路径按终端的当前目录解释。默认的两份样例则按脚本所在目录定位,所以从其他目录启动默认演示也能找到它们。路径不确定时,使用文件的完整绝对路径。
检查确认后,愿意自行发起可能计费的请求,再将最后的模式改为 --run:
.venv/bin/python file_cards.py --files "sample-notes/my-note.md" --question "这份笔记建议如何检查回答?" --run
程序会提示即将把所选文件文本和问题发送到 DeepSeek,再隐藏读取你的密钥。本篇不上传原始文件附件,而是把读取后的文本放进请求消息。第一次使用建议选公开或自己构造的练习资料;检查和演示阶段没有这种外发。
文件入口的消息组装函数如下,见 file_cards.py:
def prepare(paths, question):
require(isinstance(question, str) and 1 <= len(question.strip()) <= 200, "问题应为1到200字符。")
bundle = load_sources(paths)
questions = [{"id": "Q1", "text": question.strip()}]
# Separate file entry has its own total payload guard, not the chat's per-message limit.
messages = task_history() + [{"role": "user", "content": build_task(bundle["notes"], questions)}]
require(len(json.dumps(messages, ensure_ascii=False)) <= 12000, "消息序列化后超过12000字符;未发送,请减少资料。")
return bundle, questions, messages
这里同时保留 bundle 和 messages:前者用于本地追溯,后者用于模型任务。之后校验引用时,传入的也是这次 bundle["notes"],不是旧代码里写死的 N1—N3。这是从演示资料过渡到真实文件最关键的一处连接。
注意两个旧入口的边界:cards.py --validate FILE 仍针对旧的固定笔记与问题,不能直接验证本课的 F1 文件卡片;planner.py 的工具白名单也仍是 N1—N3,不能把 F1 当成它已经认识的资料。后续若整合界面与存储,需要建立稳定的笔记身份和明确的授权对应关系。
七、用一次小改动检查“版本”到底有没有保留下来
完成默认演示后,先记下那一组记录的编号。打开 sample-notes/rag.md,只在末尾新增一行“本文件用于第11篇练习。”,不要改动原有流程说明,然后再次运行 --inspect 和 --demo。
你应看到第二次的两个哈希都发生变化,新来源快照多出这句话,而旧快照保持原样。两次卡片仍可能完全相同,因为演示响应是固定的;输入快照发生变化不代表我们已经在线测试了模型对变化的反应。
还可以在项目目录独立计算当前文件的原始字节哈希:
.venv/bin/python -c "import hashlib,pathlib; print(hashlib.sha256(pathlib.Path('sample-notes/rag.md').read_bytes()).hexdigest())"
Windows 仍只替换 Python 路径。这次打印的值应与第二份快照的 raw_sha256 相同。如果文件又被修改,就不会再匹配;这恰好提醒你不能拿当前文件冒充当时版本。
最后,在准备发送前问自己:“这里面有没有一句像命令的笔记?”例如外部资料写着“忽略之前的要求”。它仍只是任务资料,不应自动获得系统指令或执行权限。本入口没有向模型提供工具,本地也不执行文件内容;但这不保证模型永远不受干扰。结构校验和引用存在性检查都不能代替语义审核。
八、常见失败与本篇验收
| 现象 | 先检查什么 |
|---|---|
| 文件找不到 | 当前目录和引号;相对路径不是相对脚本目录 |
| 提示不是有效 UTF-8 | 在编辑器另存为 UTF-8,不靠改扩展名修复 |
| 目录、PDF、Word 被拒绝 | 指定实际 .txt / .md 普通文件;暂不递归扫描 |
| 文件为空白或含 NUL 字符 | 确认选中了文本文件,检查导出结果 |
| 文本或消息超限 | 选取更小而完整的资料;本课不会暗中截掉尾部 |
给 --demo 传自定义文件时报错 |
演示固定使用配套资料与问题;自定义先走 inspect/dry-run |
| 改了样例后演示引用失败 | 固定演示引用的原句被删改了;恢复原句或重新解压样例,不期待演示重新生成答案 |
| 输出合法 JSON 仍未通过 | 看 result 的 error,可能是引用不在本次资料、问题不匹配或回答截断 |
| 记录保存失败 | 检查磁盘与目录权限;已收到响应后的保存失败也不会触发重发 |
本地检查不会强制把文件限制在项目目录内,它允许你明确选择其他路径。这适合本人使用的命令行工具,不能原样当作公开网页的文件读取接口。
另外,records 包含笔记明文和绝对路径。分享代码包时不要顺手把运行记录一起发出去;要清理某次练习记录,可按同一运行编号识别整组文件,确认不再需要后再删除。原始笔记不在这组生成记录里。
完成本篇,请能回答三个问题:
- 同样叫
F1的两张卡,能否直接认定来源相同?不能,要连同各自运行编号和来源快照核对。 - 卡片引用原文存在,能否认定答案正确?不能,原文还必须真正支持答案,资料本身也可能过时。
- 哈希还在,正文快照被删了,能否靠哈希恢复旧文字?不能,哈希不保存可还原的原文。
本篇代码通过60项离线测试,覆盖之前的请求、重试、计费与工具流程,以及新增的编码、大小、路径、动态引用和来源保存边界;没有真实 API 效果测试。到这里,你的助手已经有了一个可核对的文件入口。下一篇会处理新的问题:资料多起来后,怎样只挑相关片段交给模型,并让引用仍能追溯到原文。