这一课完成什么
亲手把训练与写诗能力做成可调用的命令行工具。CLI 的意思就是“命令行界面”:用户输入一行文字,程序读取要求、调用功能、返回结果。这一课既教使用,也教编写。
把一句命令沿着调用链追到底
命令行不负责凭空写诗。它把用户的文字参数整理好,选择功能,再调用前面做好的模型与生成程序。
用文字逐步读这张图
- 启动入口:./poet;找到项目的 Python
- 读取参数:argparse;--start 春 → 字段
- 选择功能:main 中的分支;train / write 等
- 调用模型:Writer → load_model;加载参数与字表
- 生成并检查:逐字采样、候选比较;返回结构化结果
- 显示或保存:四行诗 / JSON;报错走标准错误
为什么 JSON 里不能混入进度提示?
调用方要直接解析 JSON。额外文字会破坏格式,提示应走日志或标准错误。
先做三个逐步增加能力的小版本,再把它们与完整的 poet 对照。前面第 8—19 课完成 GPT 和训练;第 20—23 课完成逐字生成。现在要把这些能力连接成使用者拿得起来的工具。
一张图追踪 CLI 的完整调用路线
- 先看哪里
- 命令从启动脚本开始,最后返回诗句或可供程序读取的数据。
- 这说明什么
- 训练产生的 best.pt 是写诗阶段的输入;训练和写诗是两次不同的程序运行。
- 你接着做
- 照第 24 课三个渐进版本动手,再阅读完整入口。
第一步:只读取参数,暂时不加载模型
打开 lessons/24/build_cli/01_arguments.py,先看整个文件。它只用 Python 自带的 argparse,这个模块负责把命令中的文字整理成我们能访问的字段。
import argparse
import json
parser = argparse.ArgumentParser(description="我的第一个写诗命令")
parser.add_argument("--start", default="")
parser.add_argument("--keywords", default="")
parser.add_argument("--form", choices=["five", "seven"], default="five")
args = parser.parse_args()
print(json.dumps(vars(args), ensure_ascii=False, indent=2))add_argument 声明接受什么输入;default 是用户没填时采用的值;choices 限制可选内容。parse_args 才真正读取输入。于是 --start 春 会变成 args.start == "春",不是模型自动理解了一段聊天。
.venv/bin/python lessons/24/build_cli/01_arguments.py --start 春 --form five
# 故意输入不支持的外形,观察提示
.venv/bin/python lessons/24/build_cli/01_arguments.py --form eight第一条应输出含 start、keywords、form 的 JSON;第二条应指出只能选择 five 或 seven,并返回非零。这个小版本不会产生诗文,这是本步骤的正常结果。
下载第 1 步完整代码 · 想修改时先复制为同目录下的 my_arguments.py。

CLI 第一步:先看懂用户输入
- 先看哪里
- --start 春 变成 start 字段;--form five 变成 form 字段。
- 这说明什么
- 这一步没有加载模型。先把输入转换成清楚的数据,再接入写诗功能。choices 会限制只能填写 five 或 seven。
- 你接着做
- 先运行 01_arguments.py;把 five 换成 seven,再故意试试 eight,看帮助信息如何报错。
第二步:把参数交给模型,再打印四行诗
打开 02_write.py。开头几行 ROOT 和 sys.path 是为了让这个独立练习找到项目里的 Python 包;它们处理文件位置,与训练算法无关。保留文件所在的 build_cli 目录即可。
真正增加写诗能力的是下面这段。先建立 Writer,它会读取训练好的参数与配套字表;再调用 write,并明确告诉它开头、关键词和行长。
writer = Writer(DEFAULT_RUN / "best.pt", device=args.device)
poems = writer.write(
start=args.start,
keywords=parse_keywords(args.keywords),
form=5 if args.form == "five" else 7,
)
print("\n".join(poems[0]["lines"]))parse_keywords 把“秋雨,故乡”拆成列表并检查内容。five 转成数字 5,是因为生成函数要按数字决定每行几个字。结果 poems 是列表,第一项就是 poems[0],其中的 lines 是四行文字。"\n".join(...) 用换行连接它们。
.venv/bin/python lessons/24/build_cli/02_write.py --start 春 --form five --device cpu
.venv/bin/python lessons/24/build_cli/02_write.py --keywords 秋雨,故乡 --form seven --device cpu图中使用 CPU,便于同一设置下对照输出。想使用本机自动选择的设备,可以省略 --device cpu。写诗时只读取已经训练好的模型,不会重新训练 18000 步。
下载第 2 步完整代码 · 先核对指定开头与字数,再评价诗意是否通顺。

CLI 第二步:接上已训练的模型
- 先看哪里
- Writer 读取 best.pt;write 接收开头、关键词和行长;最后把四行拼起来显示。
- 这说明什么
- 命令行负责整理输入和展示结果;选字由 generate.py 调用 GPT 完成。这里使用正式的 18000 步模型,不会在每次写诗时重新训练。
- 你接着做
- 运行 02_write.py,再回到第 20—23 课核对逐字采样、固定字数和候选比较各自负责什么。
第三步:让人和其他程序都能使用结果
打开 03_output.py。这个版本增加 --count、--json、--output 和 --checkpoint。两个输出通道要分清:标准输出放正常结果,标准错误放提示;这样其他程序可以直接读取 JSON,而不被提示文字打断。
if args.output:
write_json(args.output, poems)
if args.json:
print(json.dumps(poems, ensure_ascii=False, indent=2))
else:
for poem in poems:
print("\n".join(poem["lines"]))--json 是开关,传入时变为 True。--output 后面接路径,既可以保存结果,也可以同时在终端显示。ensure_ascii=False 让汉字保持可读,indent=2 让嵌套结构容易查看。
.venv/bin/python lessons/24/build_cli/03_output.py --keywords 明月 --json --output reports/my-cli-poem.json --device cpu
# 故意输错,应该有中文提示,没有诗文
.venv/bin/python lessons/24/build_cli/03_output.py --count 0 --device cpu
# 紧接着查看上一条命令的返回码,应为 2
echo $?函数 main 正常结束返回 0;遇到已知输入问题返回 2;最末尾 raise SystemExit(main()) 把这个数字交给终端。不要把所有异常都当成功继续,否则文件没写好、模型没找到时,使用者还会误以为操作完成。
下载第 3 步完整代码 · 查看这一步的实际 JSON 输出。

CLI 第三步:输出与报错各走各的通道
- 先看哪里
- 正常诗文或 JSON 写到标准输出;中文提示写到标准错误;失败返回 2。
- 这说明什么
- 数量为 0 的命令确实失败了,因此 stdout 为空、stderr 有说明。调用它的程序能根据退出码决定是否继续,而不会误把报错当成诗。
- 你接着做
- 运行 03_output.py 的 --json --output 示例;再运行 --count 0,对比两次的输出和退出码。
第四步:把各项功能接到总入口
三个练习版本帮助你逐步理解写诗入口;项目最终的完整实现位于 poetry_gpt/cli.py。它把功能名字放在参数最前面:poet train 训练,poet write 写诗,poet evaluate 评估。
p = argparse.ArgumentParser(prog="poet")
sub = p.add_subparsers(dest="command", required=True)
write = sub.add_parser("write", help="按首字或关键词写诗")
write.add_argument("--start", default="")
args = p.parse_args()这段是结构示例。add_subparsers 创建多个功能入口,dest="command" 表示把用户选择的功能放到 args.command;例如用户输入 write,就进入 main 中的写诗分支。完整参数仍以实际源码为准。
| 命令 | 入口接到哪里 | 返回什么 |
|---|---|---|
| doctor | cli.py 的 doctor | 环境、材料、模型是否可用 |
| prepare | data.py 的 prepare | 整理后的数据文件与报告 |
| train | train.py 的 train | 训练日志、参数和恢复文件 |
| write | generate.py 的 Writer.write | 四行诗或 JSON |
| chat | cli.py 中的读取循环,同样调用 Writer | 每次输入后显示一首诗 |
| evaluate | train.py 的 load_model、evaluate_loss | 固定抽样的预测误差 |
| lesson | labs.py 的 run | 对应课程的小实验 |
| course | course_server.py 的启动、查询等函数 | 本机 HTML 学习入口 |
顺着表格读一个分支:先在 parser 找到参数名字,再到 main 找到分支,最后打开被调用的函数。你不需要把所有实现塞进一个大文件。完整源码可以直接从命令行入口打开。

把 train、write 等功能接到总入口
- 先看哪里
- dest=command 保存功能名字;main 按这个名字调用对应的函数。
- 这说明什么
- ./poet train 会进入训练分支;./poet write 会先建立 Writer。每个子命令只准备所需模块,--help 因此不必先加载模型。
- 你接着做
- 用第 24 课的功能对照表,从 parser 的参数一路找到 main 对应分支,再打开被调用的实现文件。
第五步:让使用者输入 ./poet 就能启动
项目根目录的 poet 是一个很短的启动脚本。它先找到自己所在的目录,再使用 .venv/bin/python -m poetry_gpt 启动程序。Python 随后执行 poetry_gpt/__main__.py,最终调用 cli.main。这条调用路径可以与开头流程图逐项对应。
pyproject.toml 中还有下面两行,用来为已经安装的 Python 环境生成同名命令入口。
[project.scripts]
poet = "poetry_gpt.cli:main"本机环境已经安装好。./poet 与 .venv/bin/poet 最终调用同一功能;直接在终端输入裸的 poet,则还取决于终端能否找到虚拟环境的命令目录。课程统一用 ./poet,减少这个额外条件。
./poet --help
./poet write --help
./poet train --help
.venv/bin/poet --help从别的当前目录使用时,传入 poet 启动文件的绝对路径,它仍能找到默认的模型和材料。你显式填写的相对 --output、--checkpoint 路径则相对于终端当前目录。所有课程示例都在项目根目录执行。

最后,让 ./poet 成为可用的命令
- 先看哪里
- 启动脚本先找到自己所在的项目,再使用该项目的 Python 执行 poetry_gpt。
- 这说明什么
- 这样从别的目录调用绝对路径也能找到默认模型。project.scripts 则让安装后的环境获得 poet 入口;两条路径最终都进入 cli.main。
- 你接着做
- 运行 ./poet --help,再运行 ./poet write --help。帮助列出的每一项都应该能在 parser 找到。
第六步:加上连续输入,模型只加载一次
chat 在循环外建立 Writer,在循环内读取输入、调用 write、显示结果。这样每轮输入都能复用已加载的模型。下面是帮助理解的简化结构,不包含完整输入校验;实际完整分支在 cli.py 中。
writer = Writer(checkpoint)
while True:
text = input("关键词或开头 > ").strip()
if text == "/quit":
break
if not text:
continue
# 完整版本还识别 /start,并处理错误和输入结束。
poems = writer.write(keywords=parse_keywords(text))
print("\n".join(poems[0]["lines"]))亲自启动 ./poet chat,输入 /start 春,读完后输入 /quit 退出。其他普通输入作为关键词。交互模式默认为五言;七言与更多设置使用 write。它是写诗界面,不能当成通用聊天机器人。

连续写诗:一轮输入,一轮输出
- 先看哪里
- 本次按顺序送入 /start 春 和 /quit。输入内容单列在上面,未伪造成现场键盘输入。
- 这说明什么
- chat 只加载一次 Writer,然后重复读取内容、写诗、显示结果。/quit 结束循环;默认是五言。
- 你接着做
- 在终端亲自运行 ./poet chat。输入 /start 春,读完后输入 /quit;七言和更多设置用 write。
使用完整 CLI 检查自己的理解
./poet write --start 春 --form five
./poet write --keywords 秋雨,故乡 --form seven --count 3
./poet write --keywords 明月 --json --output reports/my-poems.json
./poet chat首字写诗与关键词写诗的完整截图放在第 21、22 课。不要只看有没有四行字,还要核对关键词命中、重复、诗意和模型版本。
用另一个 Python 程序调用
import json
import subprocess
result = subprocess.run(
["./poet", "write", "--start", "春", "--json"],
check=True, capture_output=True, text=True,
)
poems = json.loads(result.stdout)
print(poems[0]["text"])用列表传参数可以避免 shell 对特殊字符的额外解释。check=True 在命令失败时抛出异常,不会把报错文字误当成诗文。JSON 模式的标准输出保持为可解析数据,提示信息写到标准错误。
路径与模型选择
./poet write --checkpoint artifacts/runs/my-first/best.pt --start 春
./poet evaluate --checkpoint artifacts/runs/my-first/best.pt --split val默认模型是交付目录里的正式 best;练习模型需要明确选择。自己的材料如果另放目录,还要传入配套的 --data。恢复训练用 latest,写诗默认用 best,两者用途见第 18 课。
设计适合排错的边界
无效输入应及时失败:没有模型、未知汉字、开头过长、数量为零、非法温度、材料版本不匹配都不能悄悄继续。帮助与中文提示让使用者知道下一步怎么处理。成功返回 0,已知输入问题返回非零。
./poet lesson 24本课实验只解析参数,不会训练或生成,用来观察 --start、--form 如何进入解析后的参数对象(Python 打印时把它称为 Namespace)。实际执行分支在 poetry_gpt/cli.py 的 main 中。
三个渐进版本的实际运行记录与错误输出都在图中。验收时依次确认:第一个版本能读懂输入;第二个能调用模型;第三个能保存 JSON,错误输入返回 2;最终总入口能显示所有功能的帮助。

从参数,到可以被其他程序读取的结果
- 先看哪里
- 左侧讲 --json、--output 和返回码;右侧运行第三版 CLI,展示真实生成结果的 JSON。
- 这说明什么
- 返回 0 表示程序正常结束。JSON 里的 text 是诗文,model_step 是模型训练阶段,keyword_literal_hits 是关键词直接出现情况;它们各自回答不同问题。
- 你接着做
- 先运行读取参数和接上模型两个阶段,再运行第三阶段。把生成数量改为 0,观察中文错误提示和返回码 2。
小练习与答案
为什么程序调用模式不应把进度提示和 JSON 混在同一份标准输出里?
查看答案
调用方通常会把标准输出直接交给 JSON 解析器。额外提示会使合法结果变成无法解析的文本。人类提示适合标准错误或显式日志文件;结构化结果留在标准输出。