第 02 章:用 JSONL 对话历史延续对话
适用版本:0.2.x · 源码与发布标签:chapter-02 · 程序版本:0.2.0(2026-10-21 发布)
告诉 Mino“我最喜欢的颜色是蓝色”,等它答完,再退出。重新打开程序追问颜色时,刚才那个进程已经不存在了。对话要接得上,Mino 就得事先保存这次问答,再把它交给模型。这一章,你会沿着本地文件里的记录,找到前文进入下一次请求的路径。
请使用 chapter-02 源码,前置条件及安装方法见准备与安装。在这个过程中,还要分清两个容易混在一起的时刻:回答什么时候显示了,Mino 又在什么时候确认它已经保存。
1. 关掉程序,再问一次颜色
在 chapter-02 的仓库根目录启动 Mino,告诉它一条稍后能在请求中认出来的信息。
go run ./cmd/mino交互示意,下面展示操作过程,并非真实 API 调用记录。
Mino - Chapter 02: Conversation History
History is saved locally and restored on startup. Use /exit, Ctrl+D, or Ctrl+C to quit.
You> 我最喜欢的颜色是蓝色。
Assistant> 你最喜欢的颜色是蓝色。
You> /exit
Goodbye.再运行同一条命令,然后问:“我刚才说最喜欢什么颜色?”
Mino 在接受输入前恢复已完成的问答。它不会把旧对话重新打印一遍,重启本身也不发送模型请求。只有你提交新问题时,前面的问答才一起送给模型。模型这次有了那句颜色信息作为上下文,具体怎么回答仍由服务决定。
同一次运行里的追问,也会带上已完成的问答。这两种情况下,模型都不会直接读取你 Mac 上的文件。保存对话,让 Mino 能取回它;把前文放进请求,模型才能使用它。
2. 给这段对话留一本账
“会话”把属于同一段对话的问答归在一起;“一轮问答”从你的输入开始,顺利完成时,以完整回答结束。当前版本只保留一个会话,存在一个文件里,每次重启都打开同一个文件。
2.1 每条记录占一行
只把问答留在内存里,可以支持进程退出前的追问,却解决不了开头的重启问题。程序也可以在每轮结束后读写一整份 JSON 文档,或者用数据库管理记录。Mino 当前选择 JSON Lines(JSONL):每行一条 JSON 记录,新记录追加到文件末尾。
对于本章的一段对话,这样做的好处是记录可以逐行查看,不必引入数据库操作,也不用每问一句就重写全部历史。代价也在这里:追加几行,并不能保证整轮问答要么全部写完、要么完全不写。进程可能在问题、回答和结束记录之间停下。Mino 因此需要明确记录每轮在哪里结束,再判断哪些内容可以恢复。
表 02-1:颜色问答会留下三条记录,结束记录决定它能否进入后文。
| 记录类型 | Mino 何时保存 | 留下的依据 |
|---|---|---|
user_message | 发出请求之前 | 这一轮已经提交了什么问题。 |
assistant_message | 模型成功完成生成后 | 完整回答文本,以及返回的响应输出项。 |
turn_end,status: completed | 和回答记录一起保存 | 这一轮成功结束,保存确认后可以加入后续上下文。 |
turn_id 把这三条记录配成一轮。每轮获得一个由 32 个小写十六进制字符组成的新 ID,本轮记录共用它,后面的轮次不能复用。文件中的 seq 从 1 开始连续递增,每条记录都带着格式版本 v: 1。Mino 加载时检查这些字段和记录顺序,将问题、回答及完成记录正确配对。
2.2 用会话 ID 命名的文件
这个文件放在哪里?第一次运行之后,列出会话目录看看。
ls ~/.mino/sessions/交互示意,你的 ID 会不同。
3f9c0a1b2c4d5e6f708192a3b4c5d6e7.jsonl第一次启动时,Mino 创建 ~/.mino/sessions/,并用一个新的会话 ID 命名一个空文件,ID 格式和轮次 ID 相同,也是 32 个字符。之后每次启动,程序都打开这同一个文件。记录里没有 session_id 字段:文件名已经说明了它们属于哪段对话。
只有一段对话时,用 history.jsonl 这样的固定文件名会更好找。Mino 当前却选择用 ID 命名文件。好处要到后面才显现(这是分析,并非作者陈述的设计目标):之后的版本可以在这个文件旁边增加更多对话,而不必搬动或导入它。第 05 章(chapter-05)正是这样做的:加入 /new 等命令,同时继续使用这个文件里的对话。
代价现在就能看到。你得列出目录才能找到文件;这个版本也需要规定,目录里出现不止一段对话时怎么办。程序不做猜测:如果发现多个以 ID 命名的文件,启动就会停止,并报告 ~/.mino/sessions 中有不止一段对话。
锁文件 ~/.mino/sessions.lock 只允许一个 Mino 进程使用这段已保存的对话。Mino 先拿到锁,再查看目录,所以两个同时启动的进程不会各自创建一段对话。只能使用一段对话,是 Mino 当前的设计范围,不是 JSONL 格式的限制。/new、/clear 等命令在本章尚未实现。
3. 文件里的蓝色,怎样回到模型面前
文件里保存的内容,并不都适合交给模型。“对话历史”记录发生过什么,包括失败或中断的尝试;“上下文”是当前生成时实际提供给模型的信息。Mino 按顺序还原已完成的问答,在内存中准备好,再接上当前问题,组成请求的 input。
于是,“我刚才说最喜欢什么颜色?”前面,就有了那句蓝色和对应的回答。从 ~/.mino/SOUL.md 加载的指令,仍然单独放在 instructions 中。
Mino 保持 store: false,每次请求都显式提供这些前文,不通过 previous_response_id 或服务端管理的 conversation 来关联请求。检查请求本身,就能看清哪些信息被接了过来。
再跟着图走完新的一轮。文字仍然在模型生成时陆续显示,但要接受下一个问题,程序还需要在生成结束后多等一次确认。
图 02-1:回答可以先显示,下一句要等这一轮完整保存后才能开始。
小屏幕上可以横向滚动图表。成功路径会经过两次存储确认:请求模型前,Mino 写入输入,再同步文件以确认这次写入;生成成功后,程序写入并同步回答和 completed 结束记录。只有这一步成功,本轮才会加入内存中的上下文,You> 才再次出现。
作为回答保存的,具体是什么?text 留下你读到的文本,可选的 output 则留下结构化响应项。服务返回助手消息时,Mino 保留消息及其中的 phase,也保留返回的推理项和不透明的 encrypted_content。请求中的 include: ["reasoning.encrypted_content"] 用来索取这份状态。Mino 不显示、不解密它,只负责继续传递,避免接续对话时丢掉这部分响应信息。
兼容服务如果在完成时没有返回输出项,Mino 会用成功完成的流式文本构造助手消息。终端标签 Assistant> 来自程序,不属于回答。下次发送的是问答内容和受支持的响应状态,不是把终端画面照抄一遍。
4. 半句话,可以留下痕迹,但不能算完成
假设 Mino 已经保存了你的问题,连接却在回答到一半时断开。文件里有输入,却没有一份成功完成的回答。如果把它恢复成完整问答,下一次请求就会拿到一段不符合实际完成情况的历史。
请求失败时,Mino 追加状态为 failed 的 turn_end;取消时,状态为 cancelled。这两种问答都不进入后续上下文。已经显示的片段留在终端,但不会被保存成完整回答。普通请求错误之后,只要失败结束记录保存成功,你还可以继续提问。Ctrl+C 则取消并退出。
成功完成的拒绝也算一份回答。Mino 显示 Model refused: ,保存时只留下模型的拒绝文本和返回的输出项。程序添加的前缀不会进入下一次请求。
如果进程在保存任何结束记录之前就停止,下次启动恢复时,Mino 会用 interrupted 状态结束这轮问答,将它排除在上下文之外。恢复过程不会自动重发模型请求。
文件本身也可能停在半条记录上。如果最后一行没写完整,或者最后一行 JSON 语法损坏,Mino 会先在同一目录保存原始文件的私有副本,命名为 <session_id>-recovery-*.jsonl,再移除这段尾部。文件中间的无效记录、未知记录字段或格式版本,以及不合法的轮次 ID 或记录顺序则不同:程序停止加载,保持历史内容不变,不猜测该怎么修复。
再看模型已经答完的情况:回答就在屏幕上,写入或同步记录却失败了。Mino 可以选择暂时靠内存继续聊天,提示保存未能确认。这样能维持眼前的对话,但下一问可能依赖一段重启后无法恢复的信息。
Mino 当前选择在存储失败后停止聊天。这个选择牺牲了对话的连续性,换来的是不在历史保存情况无法确认时继续运行。失败发生的位置也很关键:输入保存失败,模型请求不会发出;回答或结束记录保存失败,回答可能已经显示,但后续问题不会再发给模型。
停止聊天不会撤回已显示的文字,也不会回滚文件中已经写入的部分。 写入或同步报错,不能准确说明哪些字节已经持久保存。Mino 无法承诺本轮存好了,也不能断言什么都没写进去。下次启动时,程序重新检查文件,根据有效的完成记录和前面的恢复规则,决定哪些问答可以进入上下文。
5. 别只看它答对蓝色,要看蓝色怎么过去的
回到颜色问题。模型回答“蓝色”,与成功恢复的结果相符,却不足以证明恢复成功,因为它也可能猜中。有效的检查要跟着保存的问答进入第二次请求,包括那些从未显示在屏幕上的响应项。
在 chapter-02 的源码根目录运行下面的命令。
go test ./internal/... -run 'TestRunRestoresConversationHistory|TestRunFailedTurnDoesNotEnterContext|TestConversation|TestHistory|TestRunStreamsBeforeResponseCompletes'这些测试使用临时用户目录和本机模拟 HTTP 服务,不读取你的真实历史,也不调用付费模型。通过时,internal 和 internal/agent 包都会显示 ok;测试进程需要能够绑定本机端口。
重启检查沿着开头的问答展开:提交颜色,关闭 Mino,重新启动,再追问。测试直接查看第二次请求,核对原始输入、返回的推理与回答项,以及新问题。模拟服务提供已知的输出,检查因此不依赖模型记住或猜中颜色。
测试也会检查保存下来的内容。两次运行写入的是 sessions/ 下同一个、也是唯一一个文件。两轮已完成的问答留下六条记录,seq 连续,没有 session_id 字段;每轮内部共用有效的 turn_id,两轮之间使用不同的 ID。会话文件和 sessions.lock 都是私有权限(0600),目录为 0700。这样就能把磁盘记录和重启后发出的内容对应起来。
同一条命令也验证各种边界:在示例问答的每个字节位置截断,都不能重放未完成的问答;模拟写入或同步失败,必须阻止后续请求;第一个进程持有锁时,第二个进程不能同时打开这份历史;目录里再多一个以 ID 命名的文件,启动必须拒绝,而不是从中挑一个。另一个用例在文字显示后制造保存失败,检查第二个问题没有到达模型。流式检查仍要求文字先于完成事件显示。
这些测试验证的是给定样例和模拟故障下的恢复规则与控制流程。它们不证明回答质量,也没有覆盖一切真实磁盘故障,更不能把一次失败的同步当作持久保存的保证。
6. 本章小结与下一步
关掉程序后,那句颜色信息有了回来的路径:Mino 把已完成的本轮问答保存在唯一的会话文件里,启动时恢复,再随追问一起发送。开头的追问不再只靠屏幕上留下的前文,你可以沿着文件和请求,查清它得到的上下文。
目前每次请求仍会重放全部已完成的历史。对话增长后,可能超过模型的上下文限制;上下文压缩和上下文预算还在规划中。
chapter-02 下一步缺少的是行动。第 03 章(见 chapter-03 版本)补上这次交接:模型提出工具调用,你批准命令,程序再把工具结果交回模型。后面的步骤见章节规划。