← 全部极客知识库

大模型通识 / 大模型应用开发入门

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 请求体。

请独立检查三组关系:

  1. 打开最新 -result.json,卡片引用的 note_id 应为 F1。打开同编号 -sources.json,确认 F1 确实对应 rag.md。
  2. 把卡片的 quote 与快照中 F1.text 对照。引用应该逐字存在;再用自己的理解判断它是否支持答案。
  3. 打开同编号 -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 效果测试。到这里,你的助手已经有了一个可核对的文件入口。下一篇会处理新的问题:资料多起来后,怎样只挑相关片段交给模型,并让引用仍能追溯到原文。