昨天結束在一個沒解決的問題上,
8/25 那場課表開 1000m×5~6,我跑到第三趟覺得撐不太下去,後三趟自己改成 800m,而 sessions.json 裡那筆紀錄長這樣:趟數 5、配速 5/5 全在範圍內—兩個數字都漂亮,但自己當下決定改課表這件事,沒有地方記錄到。
尤其是紀錄的備註把它寫成:
縮短單組距離(1600m → 1000/800m)換來快 6–7 秒/400m 的配速,是合理的區塊推進。
聽起來像計畫中的安排。那是從數字反推出來的—而數字裡本來就沒有「當下狀態」的表示。
前面十幾天做的事,本質上都是把結構化的資料搬來搬去:Garmin 的 JSON、體重機的 13 bytes、Notion 的巢狀 block。它們格式各異,但至少都是機器產生的、有固定形狀的東西。
今天處理的是完全不同的輸入:
今天 12K 有點喘
這句話沒有欄位、沒有格式,甚至沒有明確說是哪一天、什麼配速、喘到什麼程度。
而這種訊息,才是實際上最常發生的紀錄方式—跑完站在路邊,掏出手機打一行字補充狀態資訊。
如果要用程式解析這句話,得處理的變化太多了:「12K」可能寫成「12 公里」「12km」「十二 K」;「有點喘」可能是「還好」「快往生」「腿很重」;還有其他很難預期的說法。
寫正則表達式去窮舉?寫到第二十條規則就會發現,每多支援一種說法,就多一分寫錯的風險,而且永遠有沒想到的講法。
這正是 LLM 適合的地方—它不需要你先定義好所有可能的句型。
這個設計最反直覺的地方在這裡。
一般會想像的流程是:bot 收到訊息 → 呼叫 LLM 把它解析成 JSON → bot 拿到 JSON → bot 寫進檔案。LLM 在中間當一個「文字轉結構」的翻譯器。
但實際上的做法不是這樣,bot 沒有解析、也沒有寫檔案,它做的事只有一件:把訊息原封不動交出去。
async def on_message(update, ctx):
if not _authorized(update):
return
# ...快捷按鈕的處理...
await _drive_claude(update, ctx, update.message.text)
最後那行就是全部了。使用者打的字直接送進 _drive_claude,由它啟動一個 claude CLI 的行程,在訓練紀錄那個資料夾裡執行:
proc = await asyncio.create_subprocess_exec(
*cmd,
cwd=str(COACH_DIR), # 工作目錄 = 助教的資料夾
stdin=asyncio.subprocess.PIPE,
...
)
CLI 啟動時會讀取那個資料夾裡的 CLAUDE.md—也就是 Day 14 那份助教人設與紀錄規則—然後自己決定要不要寫、寫成什麼樣子、寫到哪個檔案。
換句話說,「怎麼記錄一筆訓練」這個邏輯,不是寫在 Python 裡,是寫在一份 markdown 文件裡。
好處是改規則不用改程式。 想調整紀錄格式,多記一個欄位及改變判斷標準,修改 CLAUDE.md 就行,一行 Python 都不用動。
代價是輸出不保證。 傳統程式寫入資料庫,格式一定正確。這裡的最後一哩路是自然語言,理論上每次的結果可能不一樣,也可能寫錯地方。
實務上是靠 CLAUDE.md 把規則寫得夠明確來收斂—但這是「引導」而不是「保證」,這個區別要誠實面對。
還有一個實際的安全考量。CLI 是用這個權限模式啟動的:
PERMISSION_MODE = os.environ.get("PERMISSION_MODE", "acceptEdits")
acceptEdits 代表自動允許讀寫檔案,但不會執行任意 Bash 指令。這是刻意選的:紀錄訓練需要寫檔案,但不需要跑系統指令。而且那個工作目錄裡刻意不放 .env 和程式碼—助教碰得到紀錄,碰不到鑰匙。
如果每則訊息都開一個全新的對話,會很難用—你說「今天 12K 有點喘」,它記錄完;接著你補一句「配速大概 5:30」,它會完全不知道你在講哪一次。
所以每個對話都維持一條連續的 session:
sid = _sessions.get(chat_id) if resume else None
if sid:
cmd += ["--resume", sid] # 續上既有對話
else:
sid = str(uuid.uuid4()) # 新對話
cmd += ["--session-id", sid]
但對話不能無限延長—session 越長,每次帶的上下文越多,token 燒得越兇。所以加了閒置自動重開:
SESSION_IDLE_MINUTES = float(os.environ.get("SESSION_IDLE_MINUTES", "30"))
超過三十分鐘沒講話,下一則訊息就自動開一條新的。這個數字的邏輯是:訓練紀錄的對話通常是一陣一陣的—跑完步那幾分鐘會連續講好幾句,講完就沒事了。三十分鐘後再開口,通常已經是不相干的另一件事。
也有 /new 指令可以手動清空重來。
逾時要收屍。 CLI 有可能卡住,所以設了逾時,但光是 kill 不夠:
except asyncio.TimeoutError:
proc.kill()
await proc.wait() # 收屍,避免 zombie process 累積
raise RuntimeError(...)
少了 await proc.wait(),被移除掉的行程會變成殭屍留在系統裡,久了會累積。
訊息要切段。 Telegram 單則訊息上限 4096 字,助教有時候會回一長串分析:
for i in range(0, len(reply), 4000):
await update.message.reply_text(reply[i : i + 4000])
管線通了,訊息會變成 markdown 紀錄。可是昨天那個問題還沒解掉。
因為 parse_logs.py 抽的是這幾個欄位:
{date, kind, summary, moves, reps, plan}
距離、配速、心率都有欄位,「吃不消」沒有。 話進到 markdown 的散文裡,結構化那層依然看不到它—跟 Day 15 提到的問題是一模一樣的:人看得到,程式看不到。
所以今天多做一件事:CLAUDE.md 加一條規則,把使用者講的話拉成獨立一行。
自述:課表開 1000m×5~6,跑到第三趟覺得吃不消,後三趟自己改成 800m。
規則裡最重要的是兩條不准:
- **不要改寫成分析。** 備註那一段是你事後推論出來的,自述是他當下的理由 —
兩者不能混在一起。
- **不要自己編。** 使用者沒講,就沒有這一行。從數字反推出來的原因寫進備註,
不是自述。
第二條是針對開頭那個「合理的區塊推進」。助教很擅長從數字編出一個說得通的理由,而那個理由聽起來跟真的一樣,所以規則要明講,沒有加進去就不要多提。
解析端只認行首:
# 只認行首,免得備註裡提到這兩個字也被抓走。
_SELF = re.compile(r"^\s*自述[::]\s*(.+?)\s*$", re.MULTILINE)
畫面上這句擺在分組表上面,不是下面:
自述 課表開 1000m×5~6,跑到第三趟覺得吃不消,後三趟自己改成 800m。
擺下面就沒用了—因為會先看到「800m、800m、800m」那三格,很容易就把它當成計畫中的安排。
有了紀錄與資料,但還缺一個東西:判斷。
一週練了五次,是進步還是過頭?明天要用訓練負荷模型來回答這件事。