一字一诗
LESSON 24 / 26

封装可使用的命令行

这一课完成什么

亲手把训练与写诗能力做成可调用的命令行工具。CLI 的意思就是“命令行界面”:用户输入一行文字,程序读取要求、调用功能、返回结果。这一课既教使用,也教编写。

把一句命令沿着调用链追到底

命令行不负责凭空写诗。它把用户的文字参数整理好,选择功能,再调用前面做好的模型与生成程序。

把一句命令沿着调用链追到底
用文字逐步读这张图
  1. 启动入口:./poet;找到项目的 Python
  2. 读取参数:argparse;--start 春 → 字段
  3. 选择功能:main 中的分支;train / write 等
  4. 调用模型:Writer → load_model;加载参数与字表
  5. 生成并检查:逐字采样、候选比较;返回结构化结果
  6. 显示或保存:四行诗 / JSON;报错走标准错误
为什么 JSON 里不能混入进度提示?

调用方要直接解析 JSON。额外文字会破坏格式,提示应走日志或标准错误。

先做三个逐步增加能力的小版本,再把它们与完整的 poet 对照。前面第 8—19 课完成 GPT 和训练;第 20—23 课完成逐字生成。现在要把这些能力连接成使用者拿得起来的工具。

图 20 / 示意图教学流程示意图
一张图追踪 CLI 的完整调用路线

一张图追踪 CLI 的完整调用路线

先看哪里
命令从启动脚本开始,最后返回诗句或可供程序读取的数据。
这说明什么
训练产生的 best.pt 是写诗阶段的输入;训练和写诗是两次不同的程序运行。
你接着做
照第 24 课三个渐进版本动手,再阅读完整入口。

打开原图,放大阅读

查看来源

poet · poetry_gpt/cli.py · poetry_gpt/generate.py

第一步:只读取参数,暂时不加载模型

打开 lessons/24/build_cli/01_arguments.py,先看整个文件。它只用 Python 自带的 argparse,这个模块负责把命令中的文字整理成我们能访问的字段。

python
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 == "春",不是模型自动理解了一段聊天。

bash
.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。

图 10 / 浏览器截图当前源码 + 本次复现输出
CLI 第一步:先看懂用户输入

CLI 第一步:先看懂用户输入

先看哪里
--start 春 变成 start 字段;--form five 变成 form 字段。
这说明什么
这一步没有加载模型。先把输入转换成清楚的数据,再接入写诗功能。choices 会限制只能填写 five 或 seven。
你接着做
先运行 01_arguments.py;把 five 换成 seven,再故意试试 eight,看帮助信息如何报错。

打开原图,放大阅读 · 可复制的文字版

查看来源

lessons/24/build_cli/01_arguments.py · reports/illustrated/cli-parse.txt

第二步:把参数交给模型,再打印四行诗

打开 02_write.py。开头几行 ROOTsys.path 是为了让这个独立练习找到项目里的 Python 包;它们处理文件位置,与训练算法无关。保留文件所在的 build_cli 目录即可。

真正增加写诗能力的是下面这段。先建立 Writer,它会读取训练好的参数与配套字表;再调用 write,并明确告诉它开头、关键词和行长。

python
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(...) 用换行连接它们。

bash
.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 步完整代码 · 先核对指定开头与字数,再评价诗意是否通顺。

图 11 / 浏览器截图当前源码 + 本次复现输出
CLI 第二步:接上已训练的模型

CLI 第二步:接上已训练的模型

先看哪里
Writer 读取 best.pt;write 接收开头、关键词和行长;最后把四行拼起来显示。
这说明什么
命令行负责整理输入和展示结果;选字由 generate.py 调用 GPT 完成。这里使用正式的 18000 步模型,不会在每次写诗时重新训练。
你接着做
运行 02_write.py,再回到第 20—23 课核对逐字采样、固定字数和候选比较各自负责什么。

打开原图,放大阅读 · 可复制的文字版

查看来源

lessons/24/build_cli/02_write.py · reports/illustrated/cli-write.txt

第三步:让人和其他程序都能使用结果

打开 03_output.py。这个版本增加 --count--json--output--checkpoint。两个输出通道要分清:标准输出放正常结果,标准错误放提示;这样其他程序可以直接读取 JSON,而不被提示文字打断。

python
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 让嵌套结构容易查看。

bash
.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 输出

图 12 / 浏览器截图当前源码 + 本次错误演示
CLI 第三步:输出与报错各走各的通道

CLI 第三步:输出与报错各走各的通道

先看哪里
正常诗文或 JSON 写到标准输出;中文提示写到标准错误;失败返回 2。
这说明什么
数量为 0 的命令确实失败了,因此 stdout 为空、stderr 有说明。调用它的程序能根据退出码决定是否继续,而不会误把报错当成诗。
你接着做
运行 03_output.py 的 --json --output 示例;再运行 --count 0,对比两次的输出和退出码。

打开原图,放大阅读 · 可复制的文字版

查看来源

lessons/24/build_cli/03_output.py · reports/illustrated/cli-error.txt · reports/illustrated/cli-output.txt

第四步:把各项功能接到总入口

三个练习版本帮助你逐步理解写诗入口;项目最终的完整实现位于 poetry_gpt/cli.py。它把功能名字放在参数最前面:poet train 训练,poet write 写诗,poet evaluate 评估。

python
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 中的写诗分支。完整参数仍以实际源码为准。

命令入口接到哪里返回什么
doctorcli.py 的 doctor环境、材料、模型是否可用
preparedata.py 的 prepare整理后的数据文件与报告
traintrain.py 的 train训练日志、参数和恢复文件
writegenerate.py 的 Writer.write四行诗或 JSON
chatcli.py 中的读取循环,同样调用 Writer每次输入后显示一首诗
evaluatetrain.py 的 load_model、evaluate_loss固定抽样的预测误差
lessonlabs.py 的 run对应课程的小实验
coursecourse_server.py 的启动、查询等函数本机 HTML 学习入口

顺着表格读一个分支:先在 parser 找到参数名字,再到 main 找到分支,最后打开被调用的函数。你不需要把所有实现塞进一个大文件。完整源码可以直接从命令行入口打开。

图 13 / 浏览器截图当前源码阅读
把 train、write 等功能接到总入口

把 train、write 等功能接到总入口

先看哪里
dest=command 保存功能名字;main 按这个名字调用对应的函数。
这说明什么
./poet train 会进入训练分支;./poet write 会先建立 Writer。每个子命令只准备所需模块,--help 因此不必先加载模型。
你接着做
用第 24 课的功能对照表,从 parser 的参数一路找到 main 对应分支,再打开被调用的实现文件。

打开原图,放大阅读 · 可复制的文字版

查看来源

poetry_gpt/cli.py

第五步:让使用者输入 ./poet 就能启动

项目根目录的 poet 是一个很短的启动脚本。它先找到自己所在的目录,再使用 .venv/bin/python -m poetry_gpt 启动程序。Python 随后执行 poetry_gpt/__main__.py,最终调用 cli.main。这条调用路径可以与开头流程图逐项对应。

pyproject.toml 中还有下面两行,用来为已经安装的 Python 环境生成同名命令入口。

toml
[project.scripts]
poet = "poetry_gpt.cli:main"

本机环境已经安装好。./poet.venv/bin/poet 最终调用同一功能;直接在终端输入裸的 poet,则还取决于终端能否找到虚拟环境的命令目录。课程统一用 ./poet,减少这个额外条件。

bash
./poet --help
./poet write --help
./poet train --help
.venv/bin/poet --help

从别的当前目录使用时,传入 poet 启动文件的绝对路径,它仍能找到默认的模型和材料。你显式填写的相对 --output--checkpoint 路径则相对于终端当前目录。所有课程示例都在项目根目录执行。

图 14 / 浏览器截图当前入口源码 + 本次帮助输出
最后,让 ./poet 成为可用的命令

最后,让 ./poet 成为可用的命令

先看哪里
启动脚本先找到自己所在的项目,再使用该项目的 Python 执行 poetry_gpt。
这说明什么
这样从别的目录调用绝对路径也能找到默认模型。project.scripts 则让安装后的环境获得 poet 入口;两条路径最终都进入 cli.main。
你接着做
运行 ./poet --help,再运行 ./poet write --help。帮助列出的每一项都应该能在 parser 找到。

打开原图,放大阅读 · 可复制的文字版

查看来源

poet · pyproject.toml · poetry_gpt/__main__.py · reports/illustrated/help.txt

第六步:加上连续输入,模型只加载一次

chat 在循环外建立 Writer,在循环内读取输入、调用 write、显示结果。这样每轮输入都能复用已加载的模型。下面是帮助理解的简化结构,不包含完整输入校验;实际完整分支在 cli.py 中。

python
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。它是写诗界面,不能当成通用聊天机器人。

图 18 / 浏览器截图本次复现命令 · 标准输入回放
连续写诗:一轮输入,一轮输出

连续写诗:一轮输入,一轮输出

先看哪里
本次按顺序送入 /start 春 和 /quit。输入内容单列在上面,未伪造成现场键盘输入。
这说明什么
chat 只加载一次 Writer,然后重复读取内容、写诗、显示结果。/quit 结束循环;默认是五言。
你接着做
在终端亲自运行 ./poet chat。输入 /start 春,读完后输入 /quit;七言和更多设置用 write。

打开原图,放大阅读 · 可复制的文字版

查看来源

reports/illustrated/chat.txt

使用完整 CLI 检查自己的理解

bash
./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 程序调用

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 模式的标准输出保持为可解析数据,提示信息写到标准错误。

路径与模型选择

bash
./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,已知输入问题返回非零。

bash
./poet lesson 24

本课实验只解析参数,不会训练或生成,用来观察 --start--form 如何进入解析后的参数对象(Python 打印时把它称为 Namespace)。实际执行分支在 poetry_gpt/cli.pymain 中。

三个渐进版本的实际运行记录与错误输出都在图中。验收时依次确认:第一个版本能读懂输入;第二个能调用模型;第三个能保存 JSON,错误输入返回 2;最终总入口能显示所有功能的帮助。

图 25 / 浏览器截图本机工作台实际操作
从参数,到可以被其他程序读取的结果

从参数,到可以被其他程序读取的结果

先看哪里
左侧讲 --json、--output 和返回码;右侧运行第三版 CLI,展示真实生成结果的 JSON。
这说明什么
返回 0 表示程序正常结束。JSON 里的 text 是诗文,model_step 是模型训练阶段,keyword_literal_hits 是关键词直接出现情况;它们各自回答不同问题。
你接着做
先运行读取参数和接上模型两个阶段,再运行第三阶段。把生成数量改为 0,观察中文错误提示和返回码 2。

打开原图,放大阅读

查看来源

WORKBENCH.md · poetry_gpt/workbench.py · reports/workbench/browser-acceptance.json

小练习与答案

为什么程序调用模式不应把进度提示和 JSON 混在同一份标准输出里?

查看答案

调用方通常会把标准输出直接交给 JSON 解析器。额外提示会使合法结果变成无法解析的文本。人类提示适合标准错误或显式日志文件;结构化结果留在标准输出。

动手补全一小段

先运行上面的完整实验,再复制本课起始文件为自己的练习。starter 有意留空 solve 函数;补全后运行文件,底部检查会告诉你是否符合本课要求。卡住时打开参考答案,比较每一步。

bash
cp lessons/24/starter.py lessons/24/my_exercise.py
# 编辑 my_exercise.py 中的 solve 函数,然后运行:
.venv/bin/python lessons/24/my_exercise.py
# 对照完整答案:
.venv/bin/python lessons/24/solution.py

下载起始代码 · 下载参考答案

展开本课补全练习的完整参考答案
python
"""第 24 课补全练习:解析一个命令参数。修改 solve,保持下方检查不变。"""
import math, io, argparse
import torch
from torch import nn
from torch.nn import functional as F
torch.set_num_threads(2)
torch.manual_seed(26)

def solve(arguments):
    p=argparse.ArgumentParser()
    p.add_argument('--start',default='')
    p.add_argument('--form',choices=['five','seven'],default='five')
    return p.parse_args(arguments)

a=solve(['--start','春','--form','seven'])
assert a.start=='春' and a.form=='seven'
print("本课补全练习通过。")
展开本课完整、可独立运行的实验代码
python
"""本课独立实验;在仓库根目录执行 .venv/bin/python lessons/24/experiment.py。"""
import sys, json, math
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
import torch
from torch import nn
from torch.nn import functional as F
from poetry_gpt.common import ROOT, DEFAULT_DATA, read_json, read_jsonl
from poetry_gpt.model import ModelConfig, PoetryGPT, CausalAttention, Block
from poetry_gpt.data import Tokenizer, SPECIAL, clean_record, keywords_for
from poetry_gpt.labs import show
torch.set_num_threads(2)
torch.manual_seed(26)

from poetry_gpt.cli import parser
arguments = parser().parse_args(['write', '--start', '春', '--form', 'five'])
show('解析后的参数', vars(arguments))
下载本课实验