運作原理

一份長文件到底是怎麼寫出來的

四十頁的報告不是一次很長的回覆。它是一條帶狀態機的背景作業——大綱、提問、調研、逐節寫——每一步都能在當機之後重跑而不會重複勞動、也不會白燒預算;改寫的位移由程式算而不是模型挑;每一版都留著。

一次普通回答是對模型的一次請求。文件不是:它要跑幾分鐘到幾十分鐘,要扛得住你關掉分頁,還要在四十頁裡保持一致——而這四十頁裝不進任何一次請求。所以它是一條帶狀態機的背景作業,而下面幾乎每一個設計決定,都是從同一個問題推出來的:這一步死在半路會怎樣?

這幾步

步驟做什麼死在半路會怎樣
挑類型把請求對上你定義的某一種文件類型重跑,覆蓋寫
大綱產出各節建列時衝突即跳過
提問開寫之前問你一次它需要的東西等著——你的回答和喚醒作業是同一個交易
調研按節收集事實,分輪進行從下一輪接著跑
一次寫一節認領一節、寫完、再下一節
收尾統計、通知、可匯出冪等

最能說明這套設計的是調研這一步,因為它是唯一真正做不到冪等的:同一輪跑兩次,搜尋結果可能不同,抽出來的句子也可能不同。所以它靠的是另外兩樣——事實按 (文件, 節, 事實) 正規化之後唯一,重複的落不進第二列;輪次計數與那一輪的事實在同一個交易裡遞增。

結論值得直說:一個反覆在第 3 輪當機的作業,恢復之後從第 4 輪接著跑。沒有這一條,每次重啟都從第 1 輪開始,而重啟這件事本身沒有上限。當機迴圈燒不掉你的預算。

為什麼一次只寫一節

每一節由一次比較並置換(ready → writing)認領,正文、版本、計數在同一個交易裡寫完。這就是「伺服器寫到一半重啟了」不成為事件的原因:寫完的節一個字都不動,正在寫的那一節被重新認領,而不是被寫成兩個半截。

它同時讓請求保持小。一節是由它自己的寫作要求、它自己的事實和一小段上下文寫出來的,不是由整篇文件。這件事比聽起來重要:我們實測過材料變多時工具呼叫的可靠度怎麼塌——從小體量下 8 次裡成功 7 次,到 2/8,再到 5000 字上下的 0/8。把所有東西都塞進提示詞不會產出更好的一節,只會產出一節複述材料而不是按要求寫的文字。

改寫:位移由程式給

在畫布裡選取一段,說出你要改什麼,只有那一段會被替換。這條機制刻意做得很窄:

  1. 用戶端給出一個字元區間 [start, end)
  2. 伺服器取 body[start:end] 作為要改的片段。
  3. 模型收到的是那個片段加一段有界上下文,只回替換文字
  4. 伺服器拼回去:body[:start] + 替換文字 + body[end:]

模型永遠不被問區間,也從不回 diff。一個被要求給出改寫區間的模型,在沒把握時不會報錯——它會給出一個看起來完全合理的區間,然後你的文件裡悄悄少了一句話。區間來自算繪這段文字的切塊器,所以它按構造就是準的。

如果這一節在你編輯期間被改過了——比如代理程式那一刻正在重寫它——這次編輯回傳衝突,而不是覆蓋。留哪一份由你決定;這是你有權做的決定,而替你決定的那種做法,人是在幾天後才發現的。

事實,以及一個沒有出處的數字會怎樣

各節是從一張事實表寫出來的,不是從模型的記憶裡。每條事實都連著它的來源,所以成品文件裡的任何一個數字都能回溯到某一頁或某一段。

改寫之後,結果由程式掃一遍:數值、代號、專有名詞,凡是不在引用材料裡的都標出來。發現了就重新產生一次;還在,這段就帶著標記回來、而且不自動套用。特別要說的是,這道檢查不是靠問模型自己——把矛盾的材料擺在它面前問「這段有依據嗎」,它照樣回答有。

版本

一節的每一次變化都是一個版本:初稿、你的手改、每一次要求的改寫、每一次還原。還原舊版本是新增一個版本,而不是刪掉什麼,於是歷史留下的是真實發生過的事,而不是倖存下來的那一份。

對話裡會出現什麼

寫作會在對話裡留下三種訊息,而沒有一種是模型寫的

  • 開工——標題和共幾節,在作業真正開始燒 token 的那一刻發出,而不是在請求被提出時。
  • 已更新——哪幾節被改了、各自發生了什麼,在某一輪真的改動過文件時發出。
  • 完工——節數、字數、用時。

三種訊息裡的數字都是從資料庫算出來的。這不是美學偏好:一個被要求匯報自己剛做了什麼的模型,會給出一份自信、具體、偶爾為假的說明——包括在文件一個字沒變的那一輪裡說「我已經把那張圖加進文件了」。系統做過什麼,這件事歸系統說。

承諾之前該知道的限制

  • 每個資料庫跑一個寫作 worker。作業用 FOR UPDATE SKIP LOCKED 認領,認領語意本身已經容得下多個 worker,但目前的部署只跑一個。
  • 取消有兩半:行程內的那一半讓當前這一步很快停下,作業列上還有一個持久標記,所以活過行程的作業照樣停得掉。
  • 如果你的代理程式關掉了公開來源,調研這一步根本不連網——是跳過,不是降級。許可只能更嚴。