設定指南(Configuration Guide)¶
這頁教你怎麼調
config.yaml:三種覆蓋機制、最小起步、「我想做 X 要改哪個 key」速查、 三條部署階梯,以及 sandbox 與環境變數兩個最容易踩雷的區塊。分工:本頁講「跑起來後用 YAML 調哪顆旋鈕」;部署指南 講「用程式/factory 換掉整塊實作」(寫你自己的 Sandbox / FileStore / Runner);
configs/config.example.yaml是逐行權威參照(每個 key 的完整註解都在那)。本頁不重抄註解,只給心智模型 + 導覽 + 情境對照。
1. 三種設定機制與載入順序¶
設定用分層合併(layered merge):內建預設 → 你的 config.yaml 只寫 delta,其餘留在預設。
覆蓋手段,依偏好順序:
- 改
config.yaml:只寫你要改的那幾行,別的不用抄。 ${ENV_VAR}/$ENV_VAR:字串值裡插環境變數,載入時代入。密鑰一律用這個(api_key、DSN…)。 未設的變數 = 開機大聲 raise,不會靜默變空字串。WORKSPACE_APP_CONFIG=/path/to/your.yaml:改讀哪個檔。
config 檔的解析順序(python -m workspace_app):
範例檔放在
configs/,所以正式跑通常是uv run python -m workspace_app -c configs/config.yaml(或設WORKSPACE_APP_CONFIG=configs/config.yaml)。注意預設只看./config.yaml(當前目錄), 不是configs/config.yaml——「改了沒生效」十之八九是讀到別的檔(開機 log 會印出實際讀的路徑)。
開機的嚴格驗證(typo / 錯接的防線)¶
開機會 fail-loud,別怕它凶——這些訊息幫你在部署當下就抓到錯:
- 未知 YAML key → raise(打錯字防線)
- preset 參照壞掉(
workspace_chat/kb_chat/infer_modules/kb.retrieval_llm/kb.wiki.llm)→ raise prompt_file指向不存在的檔 → raise- 未設的
${ENV_VAR}→ raise agents.kb_chat解析後allowed_tools不含kb_search→ raise(KB 聊天接到 RCA preset 卻沒補工具的經典雷)kb.retrieval_llm少了preset→ raise(要關就寫null,別留半套)
開機還會吐兩份觀測資料(永遠開,無旋鈕)¶
- resolved-config dump:把「合併後的完整設定」印出來(標註每個值的來源、密鑰遮罩),
同時寫一份真值到
config.yaml旁的config.resolved.yaml(chmod 0600)。跟 example 對 diff 就知道哪些吃了預設。 - LLM 呼叫記錄:每次對外 litellm 呼叫留一筆可重播的完整記錄(見 §12 觀測)。
2. 最小起步(預設就能跑)¶
完全不寫 config.yaml 也能起——內建預設就是一套可跑組合:local sandbox、in-memory filestore、
Qwen3(透過 Ollama)、3 個 RCA picker、KB 聊天已接好 kb_search。
uv sync --all-extras
cd web && pnpm install && pnpm run build && cd ..
uv run python -m workspace_app # API + SPA 一起跑在 127.0.0.1:8000
要 KB 檢索/嵌入真的動起來,需要本機有 Ollama(bge-m3 嵌入 + qwen3 生成)。要換模型或上正式環境,
才需要 config.yaml——往下看。
3. 情境速查表(我想做 X → 改哪個 key)¶
複雜的來源是「旋鈕多」,但大部分部署只會動下面這幾格。左欄是目標,右欄是要碰的 key(區塊詳解見後面)。
| 我想… | 改哪裡 |
|---|---|
| 全部換成 OpenAI / Claude | agents.presets.*.model 改模型字串 + agents.presets.*.llm.api_key: ${OPENAI_API_KEY}(範例 2) |
| 只加一個調過 prompt 的模型到 picker | 新增一個 agents.presets.<name> + 加進 agents.workspace_chat[](範例 1) |
| KB 聊天換模型 | 加 agents.kb_chat[] 條目;接非 kb-default 的 preset 必須補 allowed_tools: [kb_search](範例 3/4) |
| 選了 VLM 當主 agent,要牠自己直接看圖 | agents.presets.<name>.vision: true(§7) |
| 檔案要持久化(重啟不掉) | filestore.kind: specstar + filestore.pg_dsn: ${SPECSTAR_PG_DSN} + disk_root |
| 上多 pod(k8s) | sandbox.kind: http + sandbox.http.base_url + 共享 filestore + 共享 MQ backend(見 §5 階梯 C) |
| 把 job runner 拆出 API | server.run_consumers: false,另跑 worker pod(§8 訊息佇列) |
| 設管理員(能讀所有 collection) | server.superusers: ["alice@example.com"] |
| 讓外部系統的網頁把工作交棒進來 | server.cors_allowed_origins: ["https://legacy-rca.corp"];沒設的話瀏覽器會在請求送出前就擋掉(串接方式見從外部系統交棒進來) |
| 限制上傳大小 / 每工作區配額 | filestore.max_file_size / filestore.workspace_quota |
| 依 App 種類給不同的 cpu / 記憶體 / 硬碟 | App 自己宣告 apps/<slug>/app.json 的 resources;部署端用 resources.per_app.default 給預設、resources.per_app.max 設天花板(超過開機失敗)。見 §6.5 |
| 限制一個人總共能用多少 | resources.per_user(count / cpu / memory 為同時活著的 sandbox,disk 為名下所有 item 的工作區總和;記在 item 的 owner 上)。見 §6.5 |
| 給某個人開特例額度 | superuser 在 /my-resources 頁尾的「個人額度」區塊設定(或 PUT /admin/user-resources/{user_id});逐維度覆寫、不用重啟。見 §6.5 |
| 換嵌入模型 | kb.embedder.model + 設 KB_EMBED_DIM 或 KB_EMBED_MODEL(改維度=要重建索引) |
| 調 KB 檢索深度(recall vs 延遲) | kb.retrieval.enhancements(expand / hyde / rerank) |
| 關掉 KB 的 multi-query/HyDE/rerank | kb.retrieval_llm: null |
| 關掉 wiki 維護 / 圖片 VLM | kb.wiki.llm: null / kb.vlm_llm: null |
| gateway 吃 session cookie / 要對背景工作收更緊配額 | 不是 config——實作 ITokenService 接進 factories.get_runner()(§11.5) |
| 模型會塞車 → 自動切備援 | preset 加 fallbacks: [...];全域門檻在 failover.*(§11) |
| 長時間 exec 不要被砍 | sandbox.exec_timeout: 0 + 設 sandbox.log_timeout(idle 上限) |
| 關/搬 LLM 呼叫記錄 | 環境變數 WORKSPACE_LLM_LOG=0 或 observability.llm_log.dir |
4. 檔案長怎樣:區塊地圖¶
configs/config.example.yaml 的區塊(全是註解,取消註解才生效):
| 區塊 | 是什麼 | 一般會不會動 |
|---|---|---|
server |
監聽位址、default_user、superusers、run_consumers、cancel 輪詢、cors_allowed_origins |
上線常改 superusers / run_consumers |
sandbox |
agent 執行環境(見 §6) | 多 pod 一定改 |
tools |
RCA 工具包怎麼佈署(prebuilt / uv-run) |
開發時改 |
filestore |
檔案儲存(memory / specstar)+ 配額 / GC |
上線一定改 |
runner |
RCA agent loop 的 max_retries / max_turns |
少改 |
message_queue |
背景 job 佇列後端(simple / rabbitmq) |
多 pod / 高吞吐才改 |
observability |
LLM 呼叫記錄 | 少改 |
failover |
忙碌時的 LLM 備援全域門檻 | 有多模型才改 |
llm |
preset 沒寫 llm.* 時的預設 endpoint + 抑制重複的取樣參數 |
少改 |
read_file / exec |
sandbox 工具的輸出上限 | 少改 |
history |
跨回合記憶的訊息數 / token 預算 | 換大 context 模型時改 |
kb |
KB 子系統(見 §9) | 用 KB 就會改 |
agents |
preset 庫 + picker + KB 聊天(見 §7) | 幾乎一定改 |
health |
sanity matrix 的 AI 評審 | 診斷時才開 |
5. 三條部署階梯¶
從最簡單走到多 pod,每階只加必要的旋鈕。
A. 本機開發(預設)¶
不用 config.yaml。sandbox.kind: local(tmpdir)、filestore.kind: memory、message_queue.kind: simple、
server.run_consumers: true(同進程消化所有 job)。
B. 正式單機(單 pod)¶
要持久化與管理員:
server:
superusers: ["alice@example.com"]
filestore:
kind: specstar
pg_dsn: ${SPECSTAR_PG_DSN}
disk_root: /data/specstar # 檔案 blob 落地處
sandbox:
kind: local
root: /data/scratch # sandbox 工作目錄(可與 disk_root 不同卷)
run_consumers 留 true:API 進程自己消化 index/wiki/card-gen/sanity 佇列(單機不用拆 worker)。
C. 多 pod(k8s)¶
多個 app pod 要看到同一份檔案與 sandbox。三件事必須共享/協調:
sandbox:
kind: http # 正式後端:獨立的 sandbox-host 服務
http:
base_url: http://sandbox-host:8000
filestore:
kind: specstar # 共享 Postgres + 共享 disk_root 卷
pg_dsn: ${SPECSTAR_PG_DSN}
disk_root: /data/specstar
message_queue:
kind: simple # 騎在共享 specstar 後端上,多 pod 零額外基礎設施
server:
run_consumers: false # API 只當 producer;另跑 worker pod 消化 job
- 為什麼
sandbox.kind: http:http後端把「哪顆 sandbox 服務同一個 item」的地址存進共享 store 並用 CAS 收斂,多 pod 才不會各開各的 sandbox 導致「檔案樹一下有一下沒」(#366)。 ⚠️ 若你堅持用kind: local上多 pod,sandbox.root必須指到共享 RWX 卷——local 後端靠{root}/{item_id}/root這個固定路徑讓每個 replica 解析到同一份活檔(#345);指到本機路徑就會資料分裂。 - worker pod:
run_consumers: false後,各 JobType 各跑一個 worker(各自 k8s HPA 擴縮): 參考kubernetes/base/workers.yaml。前提是佇列後端要共享(simple騎共享 specstar,或rabbitmq)。 - sandbox-host 是獨立專案/映像,用
SANDBOX_HOST_*環境變數設定,不吃這份 config——見 §13 環境變數 與deploy/sandbox-host.example.yaml。
6. sandbox(執行環境)★重點¶
agent 要跑 shell 才延遲開 sandbox;純檔案操作走 FileStore,不會開。三種 kind:
| kind | 用途 |
|---|---|
local |
子進程 + 暫存目錄。本機/k8s 單卷共享的預設。 |
http |
把 sandbox 跑在獨立 host pod(sandbox-host/)。正式多 pod 後端。 |
mock |
記憶體用,測試用。 |
docker已廢棄(#252)→ 改用http。
常動的旋鈕¶
sandbox:
kind: local
root: null # null = 每個 sandbox 一個 tmpdir。多 pod local:指到共享 RWX 卷(見 §5-C)
exec_timeout: 60.0 # 單一指令的「總」牆鐘上限;0 = 不限
log_timeout: 60.0 # 「閒置」上限(#70):這麼久沒任何 stdout/stderr 就當卡死砍掉;0 = 關
isolate: null # null = 自動偵測 userns jail(與下面的 isolation 是兩回事)
長任務:把
exec_timeout: 0(不限總時間)+設一個log_timeout(只要還在吐 log 就不砍)。
kind: http(正式後端)¶
sandbox:
kind: http
http:
base_url: http://sandbox-host:8000 # sandbox-host 的 ClusterIP Service
read_timeout: 0 # 0 = 不設 HTTP 讀取上限(由 host 端 timeout 收斂)
per-item OS-user + cgroup 隔離(#345,選用,預設關)¶
當多個 item 目錄並排在同一共享卷上,可把 LocalProcessSandbox 換成 IsolatedProcessSandbox:每個 item 的
exec 用 setpriv 降到一個穩定 uid(xxhash(item_id),每個 pod 都算出同一個),跑在 per-item cgroup v2 slice 下。
sandbox:
isolation:
enabled: false # true ⇒ 每次 exec 降到 per-item uid + cgroup;null = 自動(有 CAP_SETUID + 可寫 cgroup root 才開)
uid_base: 1000000
uid_range: 2000000000
cgroup_root: null # null = 自動偵測 pod 被委派的 cgroup v2 slice
memory_max: 512M
cpu_cores: 1.0
pids_max: 512
- 預設關:多數叢集禁
CAP_SETUID/SETGID與可寫 cgroup tree;§5-C 的共享目錄修法不需要它也能解資料遺失。 - 這不是
sandbox.isolate的 userns jail——是另一套模型(無 namespace)。pod 要帶CAP_SETUID/SETGID+委派的 cgroup root(見kubernetes/base/deployment.yaml)。 uv-run工具模式會強制關掉它。
sandbox-host(獨立服務,不吃這份 config)¶
kind: http 連到的 host 是自己的專案 sandbox-host/(自己的依賴/映像),用 SANDBOX_HOST_*
環境變數設定(見 §13),不是這份 YAML。app 端只要 sandbox.kind: http + http.base_url。
細節見 deploy/sandbox-host.example.yaml 與 docs/sandbox-host.md。
6.5 資源額度(per-App / per-user)★重點¶
兩個問題,兩套機制:一個 App 的 item 能吃多少(App 宣告 × 部署端封頂),以及
一個人跨所有 App 總共能吃多少(部署端的 per_user,可對個人開特例)。
disk 與 cpu/memory 的性質不同,所以做法也不同:
disk |
cpu / memory / count |
|
|---|---|---|
| 性質 | 存量——持久,sandbox 死了還在 | 流量——只在 sandbox 活著時存在 |
| 機制 | 寫入時檢查,只擋成長 | 開新 sandbox 時的准入控制 |
| 回收 | 刪檔就回來 | sandbox 被回收就自己回來(綁探活,不是計數器) |
「只擋成長」是可用性要求不是效能取捨:縮小、同大小覆寫、刪除永遠放行,否則一個人一旦 滿了就再也清不回來。
三層解析¶
resources:
per_app:
default: { cpu: 1.0, memory: 512M, disk: 80M } # App 沒宣告時吃這個
max: { cpu: 4.0, memory: 4G, disk: 20G } # App 不得超過,超過=開機失敗
per_user:
count: 3 # 同時活著的 sandbox 個數
cpu: 6.0 # 那些活著的 sandbox 加總核心
memory: 8G # 加總記憶體
disk: 50G # 名下**所有 item** 的 workspace 加總
順序是 app.json 的 resources ◇ per_app.default ◇ 今天的舊旋鈕
(sandbox.isolation.* / filestore.workspace_quota),逐維度各自往下掉。
App 那一半怎麼寫見 新增一個 App。
整段不寫 = 完全維持今天的行為:per_app 落到舊旋鈕,per_user 全零 ⇒ 每個維度都無上限,
等於沒有准入控制。0 / 空字串一律是「這個維度不設限」,不是「額度為零」。
特規使用者(個人覆寫)¶
一個數字套用到所有人一定不夠——總會有人要跑大東西。所以讀取是 個人覆寫 ◇ 全站預設,
而且逐維度:只設 count 就只有 count 變,cpu/memory/disk 照吃全站預設。
覆寫在 /my-resources 的「個人額度(管理員)」區塊設定——只有 superuser 看得到它,一般人連入口都沒有。底下的端點是同一套,要腳本化時直接用:
GET /admin/user-resources/{user_id} # 查某人目前的有效額度
PUT /admin/user-resources/{user_id} # 設覆寫,body 只帶要改的維度,例如 {"count": 10}
DELETE /admin/user-resources/{user_id} # 清掉覆寫,回全站預設
- 不是 superuser 會拿到 404 而不是 403——「某人有沒有被開特例」本身不該外洩。
- 不用重啟:下 PUT 的那個 pod 立即生效,其他 replica 最多 5 秒(memo TTL,沒有跨 pod 失效廣播;宣稱「立即」會是這個部署形狀給不出的東西)。
- superuser 名單是
server.superusers(§4 的區塊地圖)。
撞到上限會發生什麼¶
直接拒絕,不會自動幫使用者讓位(不會 LRU 回收他自己的 sandbox)。三種 507,detail.error
不同因為補救方式不同:
error |
什麼滿了 | 使用者要做什麼 |
|---|---|---|
workspace_quota_exceeded |
這個 item 的 disk | 刪這個 item 裡的檔案 |
user_quota_exceeded |
這個人所有 item 的 disk 加總 | 要刪的檔案可能在別的 item |
sandbox_quota_exceeded |
活著的 sandbox count/cpu/memory(detail.dimension 指明哪一個) |
關掉某個執行環境 |
擋在使用者訊息存進去之前(chat_send.send)、terminal 的 POST /exec、以及 workflow 執行;
item 已經有活著的 sandbox 就直接放行,那格他早就佔著了。排程觸發的 headless workflow 一樣
會被擋,但會留下可見的失敗紀錄——定時任務靜靜沒跑比擋下來更危險。
被擋的人自己解決的地方是 /my-resources:列出活著的執行環境(附關閉鈕)與各 item 的
儲存用量,兩者都配一條用量對上限的量表。實測一輪:per_user.count: 1 時第二個 item 的 exec 回
507 {"error":"sandbox_quota_exceeded","dimension":"sandboxes","used":2,"limit":1.0} → 進畫面顯示
Live environments 1 / 1 → 按 Close → 0 / 1 · No live environments → 同一個 exec 回 200。
superuser 在同一頁的最下方多一個個人額度區塊,可以直接把某個人調高(見上一節)。
兩個要知道的前提¶
cpu/memory的執行者是 sandbox 後端。 正式環境是kind: http,所以沒有重新部署 sandbox-host 就完全沒有效果。disk由本體執行,不受影響。這也是整套裡唯一沒有在真 host 上驗證過的一段。- ⚠️ 債務人是 item 的
owner,而那個欄位目前誰都能改(issue #687 仍 OPEN)。它不是 權限擁有者(權限走 specstar 的created_by),所以把owner設給別人不會失去任何控制權 ——item 照樣是你的,只有帳單跑到對方頭上。在 #687 落地前,以上額度全部可被繞過。 這是 知情的取捨(額度先做、鎖定後補),不是疏漏。
7. agents:心智模型與雷區¶
這是最容易搞混、也最常改的區塊。心智模型:
presets:LLM 後端的庫。每個 preset 綁一個model+ 系統 prompt + LLM endpoint(base_url+api_key), 可選allowed_tools/suggestions/ sandbox image。workspace_chat[]:FE 的 RCA picker。順序有意義——第一個是預設,新調查自動掛它。每條參照一個 preset,可就地覆蓋任何欄位。kb_chat[]:KB 聊天面。形狀同workspace_chat,但解析後allowed_tools一定要含kb_search,否則開機 raise。infer_modules:模組推論分類器(#66),第一條帶 per-step 設定(reasoning_effort/parallelism/collection)。
allowed_tools 是三態:省略(None)= 給預設工具集;[] = 明確清空;[a,b,c] = 就這些。
三大雷區¶
- KB 聊天接非
kb-default的 preset 卻沒補kb_search→ 開機 raise(附修法路徑)。範例 3。 - picker 第一條就是預設:想換預設模型,把它排第一。
- preset(哪顆模型)與 template 的
_config.json(哪些工具)正交合成:picker 選 GPT,工具仍來自 template(rca-tools/ask_knowledge_base照給)。想改工具去改 profile,不是改 preset。
VLM 主 agent 直接讀圖(vision)¶
主 agent 選了視覺模型(VLM)時,預設牠仍看不到圖:圖片一律繞去 kb.vlm_llm 那顆獨立 VLM 轉成文字再回來
(main → VLM → main)。兩次轉手=慢,圖轉文=掉資訊。在 preset 上標 vision: true,就告訴系統「這顆 model 自己看得到圖」:
標了之後,兩種圖都直接進主模型的眼睛,不再經過獨立 VLM、不再轉文字:
| 圖從哪來 | 行為 |
|---|---|
| 使用者在對話框夾/貼的圖 | 送出當下就內嵌進該回合的使用者訊息 → 模型直接看到,連工具都不用叫 |
agent 自己在工作區發現的圖(list_files 找到、或自己產生的) |
牠呼叫 read_image,拿回的是原圖而非文字描述 |
雷區與邊界:
- 這是宣告式旗標,不自動偵測:本地 Ollama 的 VLM model id 不在 litellm 的能力資料庫裡,自動偵測會誤判成「不支援」而默默失效——所以要你自己標。
- 預設
false=維持原本行為:純加法。沒標的 text-only 模型照舊走kb.vlm_llmdescriber,一行行為都不變。 read_image那條需要模型支援 tool calling:有些 VLM(如 Ollama 上的qwen2.5vl)根本不支援工具,那牠只吃得到「夾圖內嵌」這條路。kb.vlm_llm不能因此關掉:它仍是 KB 攝取(圖片/PDF 視覺頁)與 text-only 主模型的 describer,兩者用途不同。
prompt_file三種寫法:pkg:<dotted.package>/<file.md>(隨 wheel 打包)、/絕對路徑.md、相對路徑.md(相對這個 config 檔的目錄)。 內建 8 個 preset 與完整範例 1–4,見config.example.yaml第 415 行起。
8. 訊息佇列(message queue)¶
兩條背景佇列——wiki 維護(#59)與 KB 索引(#82)——共用一個後端:
simple(預設):job 就是共享後端上的 specstar resource,每個 pod 消化同一佇列。多 pod 零額外基礎設施 (騎 specstar filestore 本來就需要的共享後端)。rabbitmq:broker 撐更高吞吐。旗鈕(url/queue_prefix/max_retries/heartbeat_seconds…)全可選, 未設就吃 specstar 預設。⚠️ 慢的 index job 若比heartbeat_seconds久,要調高否則被回收。
搭配 server.run_consumers:
- true(預設)= 全包(本機/單 pod),API 進程自己消化。
- false = API 純 producer(仍註冊 + enqueue,只是不消化),另跑 worker pod 各消化一個 JobType(見 §5-C)。
9. KB 子系統¶
用到知識庫就會碰。重點分群:
kb:
embedder:
model: ollama/bge-m3 # 換模型 = 重建索引事件;維度用 KB_EMBED_DIM / KB_EMBED_MODEL 對齊(見 §13)
fallbacks: [] # 同一模型的副本 endpoint(不同嵌入模型會毀掉向量空間,別亂填)
chunker: { max_tokens: 256, overlap: 32 }
retrieval_llm: # kb_search 的 multi-query / HyDE / rerank 用哪顆 LLM
preset: kb-retrieval # 寫 kb.retrieval_llm: null 可整組關掉
retrieval:
enhancements: # recall ↔ 延遲的旋鈕,每個是 {default, max}
expand: { default: 1, max: 3 } # 改寫幾種問法;0=關
hyde: { default: 0, max: 1 } # 假設文件探測;0=關
rerank: { default: true, max: true }
quality_weight: 0.10 # #105 文件品質先驗強度(很小是刻意的);0=關
quality_floor: null # #105 絕對門檻:分數低於此的文件直接剔除;null=只降權不剔除
sparse_corpus_cap: null # 關鍵字(BM25)一次最多撈回幾個 chunk;null=不封頂(見下)
max_searches_per_turn: 3 # 每則 KB 回覆的 kb_search 次數上限(#195);null=不限
max_searches_ceiling: 10 # FE per-message 次數 picker 的上限(#334)
vlm_llm: { preset: kb-vlm } # 圖片/PDF 視覺頁;null=圖片上傳存 0 chunk 直到設好再重索引
wiki:
llm: { preset: wiki-default } # wiki 維護/閱讀 agent;null=關掉 wiki 維護
- 改嵌入維度 = 重建索引:
DocChunk的向量欄寬在 class 定義時綁死,改了要重跑索引。 vlm_format_llm/deck_vlm/quality_judge省略時各自 fallback(retrieval_llm/vlm_llm/retrieval_llm); 細節與 off-switch 見 example 第 292 行起。
sparse_corpus_cap —— 關鍵字檢索的封頂¶
檢索有兩條各自獨立的路:語意(向量)與關鍵字(BM25)。這個設定只管關鍵字那條。
關鍵字那條會先用 text 的三連字索引挑出「字面上像」的 chunk 再排序。問題是這個過濾只對罕見詞
有效:查 quibblezorp 可能只挑出 1 個,但查 temperature 這種常見詞,幾乎整個 collection
都「像」,等於沒縮 —— 而真實問句幾乎一定含常見詞。
sparse_corpus_cap 就是那條路的上限:一次最多只從資料庫撈回這麼多個 chunk(取最相似的),
不管有多少個命中。
| 設定 | 效果 |
|---|---|
null(預設) |
不封頂。常見詞查詢可能把整個 collection 撈回來 |
1000 |
保守起步;幾乎不影響結果,但最壞情況大幅收斂 |
200 |
更快,但 BM25 看得到的範圍變窄,漏掉的機會上升 |
為什麼封頂相對安全:語意那條路完全不受這個上限影響,照樣搜遍每一個 chunk。所以某個 chunk 就算掉出關鍵字的前 N 名,語意搜尋仍可能找到它,兩邊結果最後會合併。剩下的風險很窄 —— 只有 「只能靠字面完全比對才找得到(例如料號、型號這種語意上沒特徵的字串)」而且又剛好掉出前 N 名, 兩個條件同時成立才會漏。
調整前請先量:用 #535 的檢索評測跑一次不封頂的 baseline,設了之後再跑一次,比 recall@k / MRR, 確認沒退步再往下調。評測需要真實 collection 與可用的 LLM。
quality_weight/quality_floor在此版之前設了不會生效(loader 接受該 key 但建構時被丟掉)。 現已修正 —— 若你的config.yaml早就寫了這兩個值,升級後它們會開始真的作用,quality_floor尤其會開始剔除低分文件。
10. 其它常用小區塊¶
runner: { max_retries: 2, max_turns: 10 } # RCA agent loop
history: { max_messages: 40, max_context_tokens: 24000 } # 跨回合記憶(預設吃本地 qwen3 ~32K;換大模型調高)
read_file: { max_lines: 2000, max_chars: 200000 } # 讀檔工具上限
exec: { output_max_chars: 30000, tool_output_max_chars: 200000 }
# ↑ 單一指令輸出上限(比 read_file 小,因會跨回合累積);同一預算也管
# 列表工具(list_files / list_sources)與寫入被拒時回吐的檔案內容。
# tool_output_max_chars 是「任何一個工具單次結果」的絕對天花板,對每個
# 工具一律套用(不倚賴各工具自己記得節制)。它是保險絲,所以設在合理
# 單次答案的最寬處(一次完整 read_file);小 context 模型請調低。
goal:目標自動續跑(#613 / #615)¶
goal:
checker: { preset: "cheap" } # 判「目標達成了嗎」的便宜模型;null = 整個 goal 功能關閉
max_rounds: 3 # 上班時間的自動續跑輪數上限
offhours: # 下班時間的無人看管長跑(#615)
window: "19:00-08:00" # 空字串(預設)= 下班長跑整個關閉
timezone: "Asia/Taipei" # IANA 時區名稱
max_rounds: 30 # 一個 goal「跨所有夜晚」的總額度,不是每晚重新給
yield_after_human_minutes: 30
max_concurrent: 3
poll_seconds: 60
window跨午夜是常態,不是邊角;timezone一定要寫 IANA 名稱,別依賴容器的時鐘——base image 換了你的下班窗會跟著漂,而且沒人查得到。窗沒設或打錯、時區名稱無效 → 下班長跑關閉(不會亂猜一個時段,也不會讓每 60 秒的掃描迴圈爆掉),聊天面板上那個勾選框會直接顯示停用。offhours.max_rounds是累計的:一個 goal 花掉就沒了,所以跨三個晚上的任務跑得完,又不會變成每晚無限續命。上班的max_rounds是另一個計數器,兩者分開算。- 四種結局各自有交代:達成 / 卡住(連續 2 輪沒進展就停下等人,不會把額度燒完)/ 額度用盡 / 窗關了(還沒做完,今晚接著跑)。過夜跑過的才會在對話寫一則交班摘要 (一次便宜模型呼叫)+ 響鈴通知;白天你自己看著跑完的不會通知——為看得到的事情 響鈴只會訓練你忽略下一則。
- 哪幾天算上班日不在這裡——那是
WorkCalendar資源(網頁上/work-calendar編輯),因為國定假日和補班日年年不同,記一筆「下週六要上班」不該是改設定檔加重啟 pod。
11. 忙碌時的 LLM 備援(failover)¶
有多顆模型、任一顆會塞車時,給 preset 一條 fallbacks:(其它 preset 名,依序),忙了就切下一顆並把忙的那顆
放 cooldown。全域門檻在 failover.*,preset 可逐一覆蓋(hosted 模型要放寬 ttft_timeout_s):
failover:
ttft_timeout_s: 8 # streaming:這麼久沒吐第一個 token ⇒ 切
cooldown_s: 30 # 忙的 (model,endpoint) 被跳過多久
num_retries: 2 # 切之前同 endpoint 快速重試幾次
round_backoff_s: [1, 2, 4, 8, 16] # 整條掃完沒結果就重掃,長度=重掃輪數;[]=只掃一次
total_deadline_s: 120 # 整回合上限;到了就吐可讀的「模型忙,稍後再試」而非卡死
互動用的 head preset 把重掃壓短(有人在等);index/batch 的 head 反而要放長。範例見 example 第 235 行。
11.5 每個使用者的 LLM 憑證與呼叫 lane★接自家系統¶
對外的 LLM gateway 若不是用 bearer token 認證(例如吃 session cookie),或要對「背景工作」收更緊的
rate limit,兩件事靠同一個縫:ITokenService(src/workspace_app/tokens/)。
這個縫只做一件事:在每次 LLM 呼叫前,替「這個使用者、這條 lane、這個 endpoint」換一組憑證。 本專案不做限流——配額由對面 gateway 執行,我們只負責把憑證與 lane 送到 wire 上。
你要實作什麼¶
一個方法:
from workspace_app.tokens import CallLane, ITokenService, LlmCredential
class OurGatewayCredentials(ITokenService):
async def get_credential(
self, user_id: str, current_key: str | None, lane: CallLane
) -> LlmCredential:
session = await our_sso.session_for(user_id) # 你們的邏輯
return LlmCredential(
api_key=current_key, # 照舊送 Authorization: Bearer
headers={
"Cookie": f"OUR_SESSION={session.token}", # 名稱你們決定
"X-Call-Lane": lane, # gateway 用它套不同配額
},
)
user_id:這次呼叫替誰跑。互動 turn 是送訊息的人;背景 turn 是acting_user(見下表)。current_key:這個 endpoint 原本要用的 key(preset 的llm_api_key,或 runner 的預設)。 同一個 turn 可能有多個 endpoint(failover 鏈),每個都會各問你一次。lane:"interactive"(有人在等)或"background"(系統自己在跑)。- 回傳的
headers原樣變成該次請求的 HTTP header——litellm 對openai/*、ollama_chat/*、ollama/*三條路都照送。這個縫不決定 header 叫什麼:cookie 名稱、lane 放哪個 header、值長怎樣, 全在你們的 impl。
三條必須遵守的契約:
api_key=None的意思是「不送顯式 key」(例如本機 Ollama 不需要),不是「這個 user 沒有 token」。- 查不到這個 user 的憑證時,要嘛 fallback 回
current_key、要嘛丟例外——不可以回一個空憑證, 那等於把認證整個拔掉送出去。 - 丟出的例外不會被吞掉,但它會先被重試:
run()把它當一般回合失敗處理,在還沒有任何內容 streaming 出去之前最多重跑runner.max_retries(預設 2)次,才變成使用者看得到的錯誤訊息。 所以憑證來源掛掉時,一個回合會打你們的系統最多三次——要嘛自己做熔斷,要嘛讓它快速失敗。
怎麼插進來¶
兩條路,挑一條:
A. 改 composition root 一行(src/workspace_app/factories.py,get_runner() 裡):
return LitellmAgentRunner(
...
- token_service=PassthroughTokenService(),
+ token_service=OurGatewayCredentials(),
)
B. 不動本專案的檔案,自己組 runner 傳進 create_app(__main__.py 的做法就是這樣,
它只是把 get_runner(settings) 的結果丟進去):
from workspace_app.api import create_app
from workspace_app.api.litellm_runner import LitellmAgentRunner
app = create_app(..., runner=LitellmAgentRunner(..., token_service=OurGatewayCredentials()))
憑證會過期(session cookie 幾乎一定會)就包一層 CachingTokenService:
from workspace_app.tokens import CachingTokenService
token_service = CachingTokenService(OurGatewayCredentials(), ttl_seconds=300)
它是 {(user_id, lane): (credential, expires_at)} 的 TTL 快取,只快取成功(丟例外不會被記住),
且 lane 是 key 的一部分——把互動 turn 剛拿到的憑證餵給背景 job,正是 lane 要防的事。
它沒有「401 就作廢重取」的路:TTL 要設得比憑證壽命短。
沒接任何 impl 時的預設 PassthroughTokenService 回傳 endpoint 原本的 key、零 header,
所以現有部署的行為一字不差。
lane 是誰決定的¶
由建 turn 的地方決定,預設一律 background——漏標只會讓那次比較慢,反過來讓批次吃掉互動配額
才是這功能要防的事:
| 誰 | lane |
|---|---|
| 聊天送出(item / chat 兩個路由)、KB chat 送出 | interactive |
| goal 自動續跑、workflow node、card generation、wiki 維護 | background |
sub-agent(ask_knowledge_base) |
繼承呼叫它的 turn |
⚠️ goal 自動續跑走的是跟人送訊息同一個 ChatSendService.send,方法本身分不出來,所以 lane 由呼叫端傳。
新增一個會驅動 turn 的入口時,若那裡有人在等,記得傳 lane="interactive";不傳就是 background。
怎麼確認你接對了¶
不要只看單元測試——中間任何一層都可能把 header 弄丟,而「cookie 沒送到」在 gateway 端看起來
跟「密碼錯」一模一樣。照 tests/tokens/test_credential_reaches_the_wire.py 的手法:
起一個本機 HTTP server 當假 gateway,跑真的 runner打過去,直接讀 wire 上的 header。
目前不在這條線上的¶
kb/llm.py(multi-query / HyDE / rerank)、kb/embedder.py、kb/vlm/、health check 的 replay——
它們直接呼叫 litellm,且沒有 user 可解析,所以維持用設定檔裡的 key。若 gateway 要求所有呼叫
都帶 cookie,這幾條要另外處理(並先決定「這些呼叫算誰的」)。
另外:限流由 gateway 執行 ⇒ 429 會直接回到我方,而背景那條路目前沒有退避重試
(failover 只在設定了多 endpoint 時才會切換)。
⚠️ 憑證會被寫進 LLM log:extra_headers 會進 litellm 的 optional_params,而
observability.llm_log 的 request 區塊為了「可複製重播」刻意存真值(api_key 本來就是)。
不想讓 session cookie 落地就把它關掉(observability.llm_log.enabled: false 或 WORKSPACE_LLM_LOG=0,見 §12)。
12. 觀測(observability)¶
observability:
llm_log:
enabled: true # 環境變數 WORKSPACE_LLM_LOG=0 可直接關(正式環境 off-switch)
dir: logs/llm
keep_days: 0 # 0 = 全留(手動清)
每筆生成呼叫留完整 JSON(request 區塊就是 litellm.completion(**request) 的 kwargs,可直接複製重播,
或跑落地的 logs/llm/replay.py <file>);嵌入/rerank 只留一行索引。刪一天:rm -rf logs/llm/<date>。
13. 環境變數★重點¶
環境變數分三類,別搞混:
A. 框架行為(程式直接讀)¶
| 變數 | 作用 |
|---|---|
WORKSPACE_APP_CONFIG |
改讀哪個 config.yaml(優先於 ./config.yaml,低於 --config) |
WORKSPACE_LLM_LOG |
0 = 關掉 LLM 呼叫記錄(不用改 config 的正式 off-switch) |
WORKSPACE_AGENT_STREAM |
0 = 非串流逃生門(agent 一次抓完整回應,不逐字串流) |
WORKSPACE_TOOLS_DIR |
預建工具包目錄(等同 tools 區塊;prebuilt 模式的產物路徑) |
KB_EMBED_DIM |
嵌入維度(明確指定,優先最高)。設錯會毀掉向量欄 |
KB_EMBED_MODEL |
沒設 KB_EMBED_DIM 時,用模型名去內建表推維度(單旋鈕設法) |
KB_CODE_EMBED_MODEL |
同上,code 專用嵌入的維度來源 |
嵌入維度解析順序:
KB_EMBED_DIM明確值 >KB_EMBED_MODEL查內建表(bge-m3=1024、 nomic-embed-text=768、openai text-embedding-3-small=1536…)> 都沒設且模型不認得 → raise (不靜默給預設,免得毀了DocChunk向量欄)。空/離線 → bge-m3 的 1024。
B. config.yaml 裡的 ${...} 密鑰(載入時代入,未設就 raise)¶
名字由你在 config 裡自己取,這些只是 example 用到的慣例名:
| 變數 | 出現在 |
|---|---|
SPECSTAR_PG_DSN |
filestore.pg_dsn(specstar Postgres DSN) |
RABBITMQ_URL |
message_queue.rabbitmq.url |
LLM_API_KEY |
頂層 llm.api_key(preset 沒寫時的預設 endpoint) |
OPENAI_API_KEY / ANTHROPIC_API_KEY |
agents.presets.*.llm.api_key |
KB_EMBED_API_KEY / KB_CODE_EMBED_API_KEY |
kb.embedder.api_key / kb.code_embedder.api_key |
GIT_DEFAULT_TOKEN |
kb.git.default_token(自架 GitLab 的 PAT) |
RUN_CONSUMERS |
server.run_consumers(可用 env 控制 producer/consumer 角色) |
C. sandbox-host 服務(獨立進程/映像,不吃這份 config)¶
只有你用 sandbox.kind: http 時、設在 sandbox-host pod 上,見 deploy/sandbox-host.example.yaml:
SANDBOX_HOST_BIND、SANDBOX_HOST_UID_MIN / _UID_MAX、SANDBOX_HOST_MEMORY_MAX、
SANDBOX_HOST_CPU_CORES、SANDBOX_HOST_PIDS_MAX、SANDBOX_HOST_CGROUP_ROOT、SANDBOX_HOST_ROOT、
SANDBOX_HOST_EXEC_TIMEOUT、SANDBOX_HOST_LOG_TIMEOUT、SANDBOX_HOST_TOOLS_DIR、SANDBOX_HOST_IDLE_TTL
(+ host 綁定用的 POD_IP / PORT)。
14. 常見「改了沒生效 / 開機報錯」對照¶
| 症狀 | 多半是 |
|---|---|
| 改了設定沒反應 | 讀到別的 config 檔——看開機 log 印的 config: <path>,確認不是預設的 ./config.yaml |
開機 unknown key |
YAML key 打錯字(嚴格驗證) |
開機抱怨 kb_search |
kb_chat 接了沒 kb_search 的 preset(補 allowed_tools: [kb_search]) |
開機 ${X} raise |
密鑰環境變數沒設 |
| 多 pod 檔案樹一下有一下沒 | 用了 kind: local 卻沒共享 sandbox.root,或該用 kind: http(見 §5-C) |
| KB 搜不到東西 / 維度錯 | 換了嵌入模型沒對齊 KB_EMBED_DIM 且沒重建索引 |
| 圖片上傳 0 chunk | kb.vlm_llm 沒設 / 被設成 null |
相關文件¶
configs/config.example.yaml— 逐行權威參照(每個 key 的完整註解)- 部署指南 deployment.md — 用程式/factory 換整塊實作、生產環境注意事項
- 開發者導覽 index.md — 30 秒心智模型與抽換點
- 系統架構 architecture.md — 每層職責與為什麼這樣切
deploy/sandbox-host.example.yaml— sandbox-host 服務的SANDBOX_HOST_*設定kubernetes/base/— 多 pod 佈署範本(deployment.yaml/workers.yaml/pvc.yaml)