寫於 2026 年 8 月 25 日,9 月才上線(部落格的發佈額度在 8 月中用完了,稿子積壓了三週)。文中對這個專案的描述以 8 月 25 日的原始碼與官方文件為準,你讀到時可能已經改版。

learn-deepseek-harness 這份教材裡最關鍵的一段程式碼只有兩行:

1
2
def effect(self, dispose, label=""):
return self.fiber.collect(dispose, label)

掛事件監聽、提供服務、註冊一個 tool,全部走這兩行,而且每一次都回傳自己的反悔函式。它所在的 kernel.py 是 87 行,第 01 章就寫完了。從第 01 章到第 13 章,13 份 kernel.py 的 md5 完全相同,後面 12 章、13 個新模組全部長在這 87 行上面,一個字都沒動。

所以你要決定的其實是一個很窄的問題:手上那個週末,值不值得拿去把 14 章、2,300 行 Python 從頭抄一遍。

先把名字造成的誤會解掉

名字裡的 DeepSeek 跟 DeepSeek 的模型沒關係。它指的是被研究的那個 codebase,deepseek-ai/deepseek-harness,簡稱 dsh,一個 TypeScript 寫的 agent 框架。教學 repo 全部 .py 檔 grep deepseek 是 0 命中,需要模型的 demo 走的是 Anthropic API。不用 GPU、不用下載模型、不用 DeepSeek 的 API key。

作者 hardness1020 在 8 月中丟出這個 repo,動機寫在 README,我直接引原文:

“Reading it cold is hard because its design ideas are spread across many packages.”

上游 dsh 約 106 MB、195,849 star,什麼都是 plugin,設計概念散在幾十個 package 裡。這種東西冷讀讀不懂,導讀式的文件也救不了你,因為你會在檔案之間跳來跳去,跳完還是不知道為什麼要這樣切。

這份教學走反方向:自己寫一個 1.1 MB 的最小版 Mini-dsh,每章只加一個機制,寫完立刻用一支離線測試驗它是對的,然後給你一張表,告訴你這個機制在真實 dsh 的哪個檔案、哪個符號。貫穿全書的規則就是上游的那句 tagline:

Everything is a plugin, and every registration is reversible.

判準一:你要的是機制,還是能裝起來用的東西

沒有安裝這回事。repo 裡沒有 pyproject.toml、沒有 setup.py、沒有 CLI 進入點,pip install 不會有結果。唯一的動作是 clone,然後跑測試:

1
2
3
4
git clone https://github.com/hardness1020/learn-deepseek-harness.git

python sections/00-setup/src/test.py # 跑單一章節
for t in sections/*/src/test.py; do python "$t" || break; done # 一次跑完 14 章

這些離線檢查是整份教材的證明本體:零依賴、零 API key、零網路。14 支測試在 Python 3.14.6 上全部通過、一秒內跑完,輸出從 section 00: all checks passed 一路排到 section 13: all checks passed。全庫 133 支 .py 裡,第三方 import 只有 anthropicdotenv,而且只出現在 demo.py,其餘全部標準庫。

有個比例比較少見。最終 src/ 目錄 2,300 行,扣掉 demo.py 的 237 行與 test.py 的 171 行,純實作碼 1,892 行;14 支 test.py 合計 2,581 行。測試碼比實作碼多。敢這樣配比,是因為它的每一句主張都要在你自己機器上當場成立,不成立你馬上看得到紅字。

判準就一句:你想帶走的是可以理解的機制,還是可以 import 的套件。它是教材,不是函式庫,這件事作者在 README 裡寫得很清楚,是我們自己容易看漏。

87 行憑什麼撐住 13 個模組

所有註冊都被壓成同一個 primitive。effect() 那兩行不管接到什麼都回傳一個反悔函式,Fiber.dispose() 負責反序執行全部反悔:

1
2
3
4
5
6
7
def dispose(self):
if self.state == "disposed":
return
self.state = "disposed"
for run in reversed(self._disposers): # 反序:後掛的先拆
run()
self._disposers.clear()

反序這件事很像拆鷹架。你不會從最底層那根管子開始拆,那樣上面的東西會直接掉下來。後架的先拆,順序反過來走一遍,中間任何一層都不會踩到還在被別人依賴的東西。

collect() 回傳的是 single-shot 包裝,提早呼叫也安全,fiber 不會重複執行同一個反悔。「plugin 可以熱插拔」因此不是框架的特異功能,是這 87 行給的機械保證。

這一章跟 AI 完全沒有關係。外掛系統、事件監聽器、資源生命週期,套路一模一樣。你如果只想學這個模式,讀完第 01 章就可以關掉,剩下 13 章不看也不虧。

判準二:模型被抽得多窄,決定你能不能離線學

整份教材能離線跑,靠的是一個極窄的介面,作者叫它 Model seam。定義只有一句話:一個 callable,吃 list[Message],yield 若干個 ("chunk", str),最後 yield 一個 ("message", Message)

離線版就是照排隊順序吐罐頭回應:

1
2
3
4
5
6
def __call__(self, messages):
"""The Model seam: yields ("chunk", str)... then ("message", Message)."""
text = self._queue.pop(0)
for piece in _chunks(text):
yield ("chunk", piece) # 一段一段吐,模擬串流
yield ("message", Message(role="assistant", content=text))

這東西像駕訓班的模擬器。方向盤、油門、儀表板的介面跟真車一模一樣,引擎是假的,但你要練的那些判斷完全練得到。

換成真的 API 也只有 20 行,這是整份教材唯一碰到 Anthropic SDK 的地方:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
def live_model(messages):
import anthropic
# mini 的三種 role 壓成 API 的兩種
wire = [
{"role": "assistant" if m.role == "assistant" else "user", "content": m.content}
for m in messages
]
client = anthropic.Anthropic()
with client.messages.stream(
model=os.environ.get("ANTHROPIC_MODEL", "claude-sonnet-5"),
max_tokens=300,
messages=wire,
) as stream:
for piece in stream.text_stream:
yield ("chunk", piece)
final = stream.get_final_message()
text = "".join(block.text for block in final.content if block.type == "text")
yield ("message", Message(role="assistant", content=text))

接起來跑一個 agent 長這樣,掛在事件匯流排上,每個 chunk 一落地就印出來:

1
2
3
4
5
6
7
8
9
10
11
12
ctx = Context()
ctx.plugin(session_log_plugin)
ctx.plugin(agent_loop_plugin)
session = ctx.get("sessions").create("live")
agent = ctx.get("agents").create("a1", session, live_model)

def on_event(_session, event):
if event["type"] == "assistant/chunk":
print(event["payload"]["text"], end="", flush=True)

ctx.on("session/event", on_event)
agent.send("hi")

介面窄到六行講得完,換來的是每個機制都不用 API key 就能驗證。你自己專案裡如果模型呼叫散在十個地方,這一段是最該先抄的。

四個可以直接搬回自己專案的決定

對話歷史不存起來,每一步重新推導

session_log.py 的主張很硬:

“A Session never stores model history. It stores frozen events… derive_messages() projects the surface into Message objects on demand.”

拆成三層。log 是 append-only,事件的 seq 就是它在 list 裡的 index,永遠不變。surface 是一串 seq,只放模型看得到的三種事件(user/messageassistant/messagetool/result)。derive_messages() 每一步重新投影出訊息清單,從不快取。

寫進 log 的 payload 會先走一趟 json.loads(json.dumps(payload)),同時完成「驗證可序列化」跟「深拷貝」兩件事,再用 MappingProxyType 凍結。呼叫端事後改不動歷史。

壓縮上下文的時候不刪任何東西

第 03 章的設計題很尖:log 既然是 append-only,那 compaction 要怎麼移除模型看得到的內容?答案是 append 一個新事件,帶上一個 replace 的 surface op,讓它遮蔽 surface 上那段範圍:

1
2
3
4
5
session.append(
"user/message",
{"content": "Summary of the conversation so far: you greeted me twice."},
surface_op={"op": "replace", "start": 0, "end": len(session.log)},
)

log 一行都沒少,模型下一步只看得到這句摘要。驗證做在 commit 之前,不連續的範圍、蓋不到任何東西的範圍直接 raise,log 和 surface 都不會被動到。交易邊界處理得很乾淨。

一個 tool 爆掉,transcript 會留下一個沒人回答的問題

tools.py 的檔頭寫得很白。名字不存在、事前被拒、要核可卻沒核可、guard 擋下、參數不合法、body 自己丟例外,六個錯誤出口全部回傳結構一致的 {call_id, name, is_error, content},一個例外都不准穿出邊界。

理由是第 05 章 README 那句話,我覺得是全篇最好的一句:

“A tool’s request is written to the log before the tool runs. If it throws and nothing goes back, the conversation keeps a question nobody answered.

自幹 agent 最常踩的坑就在這裡。一個 tool 炸了,那個洞不會自己補起來,接下來每一步模型都還在看它。

還有兩個細節值得抄。權限投票只會收緊不會放寬,嚴格度 allowaskdeny 遞增,pre hook 投的票只有在更嚴格的時候才蓋掉既有結果。需要 ask 但沒設 asker,直接視為拒絕,理由是預設 allow 會讓一個沒設定過的 mini-dsh 變成權限最寬鬆的那個。

system prompt 差一個位元組,快取就全滅

這條最有錢味:

“System text must come out byte-identical from step to step, or no prompt prefix is ever reused; so dynamic state (a clock, a cwd) never renders there.”

時鐘、工作目錄這種每一步都在變的東西,一旦寫進 system prompt,等於自己動手把 prefix cache 關掉。它們改走一個「只有變了才重發」的 user message,而「上一次發的是什麼」是從 log 反查出來的,不另外存一份狀態,所以去重跟 replay 天然一致。

排序也是為了確定性。用 (order, seq) 雙鍵排,註冊順序當 tiebreak,相同的註冊必定產生相同的文字。變數插值走嚴格模式,找不到值直接 raise,請求絕不帶著一個洞出門。

這四個決定有個共同點:它們都在處理「同一份狀態被兩個地方各記一次」這個老問題,而處理方式都是把其中一份砍掉、改成每次算出來。

什麼情況下讀它是浪費時間

你要的是能用的東西,那就別碰。沒有 package、沒有 CLI、不能安裝、不能 import 進你的專案。各章的 src/ 是扁平 import,靠「腳本所在目錄自動進 sys.path」才跑得起來,搬到別的地方就散了。

你想拿它的 sandbox 當隔離,那更不行。ArgvRewriteSandbox 的 docstring 自己寫 “Rewrites argv; enforces nothing”,只改寫參數、什麼都不強制。真實 dsh 串的是 bwrap / landlock / seatbelt。絕對不要拿它去跑不信任的指令。

你想照著它接 Anthropic API,會出事。demo.py 把 tool role 壓成 user role 送出去,註解自己承認是教學簡化,真的 tool result 要用 tool_result content block。requirements.txt 三行、兩個套件、都沒 pin 版本,SDK 哪天出 breaking change,10 支 demo 會同時壞,而離線檢查依然全綠。紅燈不會出現在你預期的地方。

你卡關的時候會想搜答案,那你要有心理準備:21 star、3 fork、0 issue、單一作者、2026-08-18 建立。沒有社群,遇到問題就是自己讀碼。

剩下幾個小坑一起講掉。session log 是記憶體裡的 list,程式結束就沒了。tool body 卡住整個 turn 就跟著卡住,沒有 timeout,真實 dsh 有 around-waterfall 可以 time-box。參數只驗名字不驗型別,真實 dsh 的 defineTool() 會驗 args 與 output。「See ADR 0001」在 requirements.txt 和 10 支 demo.py 共 11 處出現,但 repo 裡 find -iname "*adr*" 是 0 個檔案,作者清 README 時漏掉了,別去找。跑過測試之後 README 教的「diff 相鄰兩章」讀法會被 __pycache__ 洗版,改用 diff -rq --exclude=__pycache__ 比對。

這篇的依據到哪裡

14 支離線檢查通過、13 份 kernel.py 的 md5 去重之後只剩 1 筆、兩個 repo 的 star 與授權,這些數字來自我先前把 repo clone 下來實跑跟用 gh api 查的紀錄,不是我寫這篇時重跑一次。demo.py 我一支都沒跑過,不想動用 API key,所有關於 live demo 的敘述都是讀原始碼得到的。Python 3.10 以下能不能跑,我沒有環境測。上游 dsh 我只確認過映射表的 11 條路徑存在,沒有讀內容,所以「符號真的一一對應」這件事我無法背書。

映射表的連結釘死在 commit 99f6f02(release dsh@0.1.0-rc.7),上游改版也不會失效,這點做得很負責。另外 14 章加根 README 全部有 README.zh-TW.md,用詞是正確的台灣術語(函式庫、套件、程式碼庫),不是簡轉繁丟出來的。

一句不騎牆的建議

你如果這半年內要讀或蓋一個 agent 框架,clone 下來,今晚就把第 01 章那 87 行抄一遍,抄完再決定剩下 13 章要不要跟。第 03 章的 compaction 跟第 05 章的 tool 邊界是全書 CP 值最高的兩章,就算不做完整套也該讀。至於只想找個能裝起來用的 harness 的人,這裡沒有你要的東西,關掉這頁去看別的。

想再往前一步的話,練習題很現成:把你自己專案裡任何一個「註冊了但很難拆掉」的東西,用 effect() 那兩行的形狀重寫一次,看它能不能在 dispose() 反序跑完之後乾淨消失。第 09 章的 skills.py 只有 108 行,把 Agent Skills 那套 progressive disclosure 寫成能跑的最小實作,作者稱之為 token economy,摘要一直付錢、本體用到才付。這個直接 python sections/09-skills/src/test.py 跑起來看,比讀規格文件直觀太多。