建造虛擬團隊系統
人很難被改變,所以我把靠自覺的部分換成規則,讓幾個 session 像一個團隊在做事。這是四條規則、五段審查、撞到的坑,和還在的限制。
先講結論:這套系統不是為了讓 AI 更快,是為了少處理「人」的問題。
同一個資料夾裡的兩個 session
我的分工是這樣:一個我開任務、審成果,幾個 session 各拿一張任務去做。聽起來像多幾個人,實際撞到的是多幾個人會撞到的每一件事。
有一次,兩個 session 待在同一個資料夾,一個開了新分支,另一個幾分鐘前才確認自己在主線上,以為還在,一則本來要立刻進主線的紀錄就落到了別人的分支上。它做到最後回頭更新任務板,才發現腳下的東西被換掉了。
問題不是哪個 session 犯了錯,是流程本身沒有機制讓對方主動知道現況換了。這類事情多幾次,我開始建這套系統。
人的問題,和多幾個 session 會撞到的事
這套系統是為了少處理人的問題而建的,所以先講有人的地方:新創、大公司,只要有人,要處理的問題跟面向就很多。一人團隊沒有這層問題,但下面的規則、審查流程、切 session 這些機制,一人團隊一樣用得上,只是少了「跟別人對齊」這一項。
我自己的經驗是,一個人如果沒有想改變,別人的指教不論用什麼方式都很難改變他。最後能調整的只有自己。但把自己一路調整上去,結果只是跟不長進的人差距越拉越大,團隊的問題本身一個都沒少。
團隊裡很容易遇到這件事,應對方式也很多。但如果有更省力的方式呢?把「等別人改變」這件事從流程裡拿掉:工作交給幾個承接我做事方式的 session,我只留規則和 review,用規則取代自覺。
只是 session 換掉的是人,沒換掉協作本身的問題。開了幾個 session 之後,撞到的是這四件事:
- 相依性任務衝突。有些任務彼此相依,隔不乾淨。規則還沒立好的時候,session 1 做 A、session 2 做 B,兩邊都沒發現對方的改動,各自燒 token,做出來的品質也沒人把關。
- 板子是被動的。任務板看得到誰認領了什麼,但另一個 session 只有回頭看板子時才知道,開頭那次資料夾撞車就是這樣發生的。
- 衍伸任務重複開。做的過程發現額外需求要開新任務,彼此各自開、都不知道,兩張任務可能有重疊的內容,任務越開越多。
- 做完的任務沒即時清。做完的沒下板,開場灌給每個新 session 的東西越來越肥。
四條規則,和幾個內建功能
把靠自覺的部分,換成規則來擋。 四條規則,對上四個困難。
同綁,對困難 1。 相依到隔不開的兩張任務,不派給兩個 session。同一條分支做、一起審、一起收,一次最多綁一張,免得一條鏈變成一個沒人審得完的 PR。隔得開的,還是一張一個 session。
溝通,對困難 2。 先把板子本身搬了家:任務板原本放在主線上,現在住在自己的一條 git ref。認領或上鎖一 push 出去,每個 session 馬上看得到,以前主線上大部分的紀錄都是認領、上鎖這類板子操作,真正的工程改動被埋在底下;搬出去之後,主線只剩工程改動。
但看得到不等於會去看,板子還是被動的。所以認領與上鎖時,一支小腳本組出一行字,寫明任務、分支、要碰的範圍、在哪個資料夾,session 把這行送給其他還在跑的 session,不等對方回頭看板子。這是盡力送達:送不到就跳過,不重試,也不擋住認領,因為認領必須即時。
例:「認領了一張任務、開在 virtual-team-system-restore 分支、要碰 engines/web、我在 virtual-team-system-restore 這個 worktree」。分支名是任務內容的簡述,要碰的路徑是這條產品線固定負責的範圍,同一條產品線的任務都一樣,看到就知道會不會跟自己撞。開頭那次撞在同一個資料夾,缺的就是最後一段。
怎麼實作的:組出廣播那一行
core/scripts/board_announce.py
def announce_claim(task_id):
root = board_root(ROOT)
tasks, errors = scan_tasks(root)
task = next((t for t in tasks if t["id"] == task_id), None)
if task is None:
detail = "; ".join(errors) if errors else "no such live task"
raise SystemExit(f"board-announce: {task_id}: {detail}")
if not task["branch"]:
raise SystemExit(f"board-announce: {task_id} has no branch recorded yet "
f"-- claim it (write branch + commit + push) before announcing")
return (f"board claim {task_id}: branch {task['branch']}, "
f"territory {territory_of(root, task)!r}, {checkout_location()}")
編號,對困難 3。 衍伸任務先上板再動手。每個新開的 session 一開場就會先讀到板上的任務目錄,有人認領時,認領也照溝通那條規則廣播出去,所以要開一張新任務之前,板上已經看得到有沒有同樣的事,不是做完才發現重疊。
號碼從每條產品線一個只往前跳的計數器發,發號時拿著檔案鎖。計數器要等確認這個號碼沒人用過才往前推,所以就算撞號,也不會白白燒掉一個號碼。刪掉一張不適用的任務,號碼也不回收,歷史紀錄裡的同一個號碼永遠是同一件事。
怎麼實作的:只往前跳的計數器
core/scripts/board_add.py
def allocate(prefix, trunks, tasks):
"""Return (id, trunk name) and bump the trunk's counter, under a file lock.
Everything happens inside the lock, and the counter is only written once the
id is known to be free, so a collision leaves the counter untouched instead
of burning a number.
"""
with trunks.open("r+", encoding="utf-8") as fh:
fcntl.flock(fh, fcntl.LOCK_EX)
lines = fh.read().splitlines(keepends=True)
rows = [(i, m) for i, line in enumerate(lines)
if (m := ROW_ANY.match(line)) and m.group("prefix") == prefix]
if not rows:
raise SystemExit(f"prefix '{prefix}' is not a trunk in {trunks}")
if len(rows) > 1:
raise SystemExit(f"prefix '{prefix}' has {len(rows)} rows in {trunks}, "
f"so the counter to bump is ambiguous; leave one")
i, m = rows[0]
raw = m.group("next")
counter = raw.strip()
if not counter.isdigit():
raise SystemExit(f"trunk '{prefix}' in {trunks} has no usable next "
f"counter (found '{counter}'); set it to the number "
f"after the highest id that trunk has ever used")
number = int(counter)
task_id = f"{prefix}-{number:02d}"
if (tasks / f"{task_id}.md").exists():
raise SystemExit(f"{tasks / f'{task_id}.md'} already exists, so the "
f"next counter for '{prefix}' is behind reality; "
f"raise it past every id this trunk has used")
lines[i] = line_with(lines[i], m, number + 1)
fh.seek(0)
fh.writelines(lines)
fh.truncate()
fh.flush()
return task_id, m.group("trunk").strip()
雙向 check,對困難 4。 同一個習慣的兩面一起查:一面抓「寫著做完卻沒交付成果」,另一面抓「做完了卻還留在板上」。board doctor 兩面都查,它也是 gates 裡的一道。
怎麼實作的:兩面一起查
core/scripts/board_doctor.py
elif status in FINISHED_STATUSES:
# [... 18 comment lines omitted ...]
if status == "done" and not t["output"]:
problems.append(f"{t['id']}: done but no output link")
# [... a second problems.append omitted: it reports "<status> and still on the board" and asks to prune the file ...]
規則管的是 session 之間怎麼不撞,另一個限制是 session limit。四個 session 同時很認真地做事,很容易頂到上限,然後等五小時 reset。頂到的那一刻不是一個停,是四個一起停。
我主用 Claude Code、副用 Codex,所以以 Anthropic 的內建功能來說:
/context先看 context 裡塞了什麼,再用/clear決定清不清。不看就清,會把還要用的東西一起清掉。/compact:在 code review 的改善回合特別有效。- 切換 model:根據任務內容切到各自擅長領域的 model,不是一路用最貴的。
- 能用程式做的盡量用程式做。腳本跑一次就有答案的事,不要叫 model 想一次。
- prompt 或給 AI 讀的文件不要寫太長。開場的東西每個 session 都要吃一次,長文件是每個 session 都付的固定成本。
這些只是把頂到上限的頻率壓低,上限本身還在,結論會再講。
審查怎麼跑,以及跑幾輪
每個改動都走 PR,審查寫成流程。
以前的流程是三步:先用一個沒寫過這段程式的 subagent 讀並且執行,有 finding 就修,修完再審,直到一輪沒東西要修;再交給另一家模型審,換一組盲點;最後真人 approve,然後合併。真人只在最後出現一次。
現在改成五段,照順序走:
- architecture:只有人。在任何 AI 看 diff 之前,先把意圖、範圍、不變條件、出錯時往哪邊錯、介面會不會變寬,寫成一份 contract 凍結。
- adversarial:AI 對著那份凍結的 contract 審,一輪接一輪,直到審整個改動的一輪只剩觀察、沒有 finding。
- regression:AI 確認修 finding 的過程沒有把已經驗過的行為弄壞。
- convergence:只有人。確認改動還在 contract 裡面,也確認沒有哪個審查者把自己的設計偏好包裝成缺陷塞進來。
- gates:固定的指令,文件檢查、board doctor、workspace doctor、完整測試、lint。
前後差在人的位置:以前人只在最後,現在人在最前面和最後面,中間交給 AI。
撞到的困難是輪數。修 finding 本身就是一次改動,所以每修一次就回到 adversarial 重審,這條回頭的箭頭拿不掉。一個極小的 PR 跑了六輪,非常耗時,也很耗 token,其中有幾輪在修我自己前一輪寫壞的敘述。
所以分段本身解決的是「誰來看」,不是「看幾次」。真正對著輪數下手的是兩條規則:審查者提出的問題先分類,指得出違反 contract 哪一條、或在具體路徑上證明會失敗的,才是 finding,要修;其他的換個設計、多個功能、風格偏好,都是 observation,記到任務上交給人決定,不擋這一輪結束。另一條是把主線合進來但 diff 一個字都沒變、gates 與完整測試也都過的,不算改動,不重新起一輪。
怎麼寫進規則的:finding 和 observation,以及合主線的例外
core/rules/review.md
4. One mechanical exception for a merge of main, all three conditions being
commands rather than judgements: `git diff origin/main...HEAD` is character
for character what it was at the end of the last round to run, whichever
stage it belonged to; gates are still clean on the merged branch; the
test suite's full suite tier (`core/rules/testing.md`) still passes on
the merged branch. Fail any one and the merge
restarts the round at adversarial.
[... rules 5 to 6 omitted ...]
7. Every candidate issue a reader raises is classified before it is reported,
and the classification decides what it is allowed to do.
1. **Finding**: names the specific clause of the architecture contract it
violates, or shows an already-authorized behavior failing on a
concrete path, with evidence: a file, a rule interaction, a repro, a
test. A finding can block the stage that raised it and is fixed inside
the task under rule 12.
2. **Observation**: violates no frozen invariant. A different design, a
capability the contract did not ask for, a wider interface, a style
preference. An observation never blocks a stage from ending, never
authorizes a change on its own, and is never filed as a new board task
by the round that raised it (rule 13 already covers filing in
general). It is recorded in the observation ledger on the task
register:
[... the observation ledger format and the rest of rule 7 omitted ...]
這兩條有沒有把輪數壓下來,我沒有量過。規則上路之後還是有分支跑了五輪,也有跑到十輪的。所以目前這方式還不是最好。
好幾個我,和還沒解決的
每一個 session 會承接歷史記憶,所以越來越像我的做事方式。過程有時候會有種錯覺,像這個團隊有好幾個我。
少掉的是人的問題:沒有懶惰、沒有能力不足、沒有找到問題卻不知道怎麼修或不願意修,也不需要細心到不行地在旁邊教。多出來的是另一種問題:那四個困難是機器撞出來的,每一個都要我回頭去修規則;審查的六輪裡,有幾輪是我自己寫壞的敘述,不是機器的錯,是我審自己的東西本來就審不乾淨;session limit 是廠商定的,修規則也修不掉。
限制也很實在。他們理論上可以 24 小時工作,但四個虛擬同事、或者說四個虛擬的我,很認真地做事,很容易頂到 session limit,然後等五小時 reset。也就是說,一個人的虛擬團隊,一次也不能找太多人。
同一套規則,乘三
一個菁英真人帶三個菁英複製人。照這個模式擴,三個真人就有九個複製人,一個團隊只要三個真人,就有 12 個菁英在做事。真人負責的是規則和 review,所以這套系統把「要多少人」的問題,換成「要多少個帶得動複製人的人」。
還沒解決的:session limit 本身,和審查要分幾段才划算。
等這套系統跑到穩定,我想要的樣子是這樣:開工前先問一句,對方回報進度,再接手。
你還要用嗎?
我正在收尾,再回報進度給你,
你再接著做
這類東西我常寫: LinkedIn 追蹤/ RSS 訂閱
← 回到所有文章
留言
留言先審後上;email 只用來回覆聯絡,不會公開。
還沒有留言,當第一個。