triAIge 架構說明(給團隊成員)

救護車上的救護員說「男性,胸痛半小時,血壓一百六十、九十,心跳一百一」,幾秒後,救護紀錄表上的性別、主訴與生命徵象就填好了,每一格都標明是 AI 填入、等人複查;病人送到急診時,檢傷護理師打開同一個案件,看到的是預先填好、附上建議檢傷級數的評估表。triAIge 做的就是這件事。前端是救護端、急診端與醫師端的網頁;後端是一個 FastAPI 服務,在一台 DGX Spark 級的主機上用 Nemotron 3 Super 擷取欄位,並在背景整理摘要、追問與分級建議;醫師端還有一個會搜尋知識庫的鑑別診斷 agent。臨床判斷交給確定性規則與護理師。這份文件說明系統現在的組成:程式分成哪幾層、一句話經過哪些層、每一類規則放在哪裡,以及新增提示詞、新增檢查或換模型時要改哪裡。

1. 這份文件給誰、怎麼讀

讀者有兩種:開發者會用到全部章節;臨床組員只需要讀第 2 章(系統全貌)、第 4 章(一句話的處理過程)與第 12 章(已知限制),第 5 到第 10 章講程式內部結構與操作,可以跳過。

這份文件遵守 eval/README.md 的誠實規則:評測用的逐字稿是模型寫的草稿,案例卡來自 MIMIC,還在等醫師審閱,所以這裡不說「已通過臨床驗證」。

2. 系統全貌

flowchart LR
  subgraph Browser["前端(iPad/瀏覽器)"]
    WEB["/ 正式介面(真後端)"]
    MOCK["/internal 模擬版(瀏覽器內 mock)"]
  end
  TUNNEL["Cloudflare Tunnel"]
  subgraph Spark["DGX Spark"]
    subgraph SRV["triaige-server(FastAPI,:8100)"]
      API["api/"]
      AI["ai/"]
      ENG["engine/"]
      TR["task_rules/"]
      LLM["llm/"]
    end
    NAT["NeMo Agent Toolkit<br/>WorkflowBuilder + guardrails middleware<br/>鑑別診斷 agent"]
    VLLM[("vLLM:Nemotron 3 Super<br/>:8000")]
    PG[("PostgreSQL :5433<br/>cases JSONB、kb_passages")]
  end
  EVAL["eval/(離線評測)"]
  WEB --> TUNNEL --> API
  API --> AI
  API --> ENG
  AI --> NAT --> VLLM
  SRV --> PG
  EVAL -.-> VLLM
圖表載入中…

決策背景見 docs/specs/2026-10-13-backend/decision.md 與 design.md,部署參數見 infra/inventory/group_vars/servers.yml。

3. 分層與依賴方向

哪些程式碼該知道「現在用的是哪個模型」?我們的答案是只有 llm/。其餘各層分成「任務本身的規則」與「執行任務的程式」,兩者都不能出現模型名稱、取樣參數,或處理模型輸出習慣的程式碼。

flowchart TB
  subgraph Interface["介面層"]
    API["api/(FastAPI 路由、SSE)"]
  end
  subgraph Application["應用層 ai/"]
    SVC["service.py 編排<br/>scheduler、sequencer、route、gate、recorded、mapping、followups、nurse_notes、call_log"]
    RT["NAT 執行期黏合<br/>extractor.py、nat_*.py、kb_binding.py、workflow.yml<br/>(workflow.yml 只有 functions 與 guardrails)"]
  end
  subgraph Task["任務層(與模型無關)"]
    ENGN["engine/(案件規則,與 web mock 對齊)"]
    TRN["task_rules/(只用標準函式庫)<br/>詞彙、record/statement/route 檢查、主訴翻譯檢查、摘要/鑑別診斷/分級/追問/護理師輸入/備忘檢查"
  end
  subgraph Model["模型適配層 llm/(只用標準函式庫)"]
    PROF["profiles/nemotron_3_super/<br/>ModelProfile"]
    PS["prompt_sets/ems_compact/<br/>record、statement、classify、translate、consolidate、hospital_level、summary、followup、nurse_note、differential、self_check"]
    ADP["output.py、wire.py、choice_scoring.py"]
  end
  KBN["kb.py 知識庫檢索(PostgreSQL 全文檢索)"]
  INFRA["infra/ 部署手冊與 group_vars"]
  VLLM[("vLLM")]
  API --> SVC
  API --> ENGN
  SVC --> ENGN
  SVC --> TRN
  SVC --> RT
  SVC --> PROF
  RT --> PROF
  RT -- "OpenAI 相容 HTTP" --> VLLM
  RT -- "鑑別診斷 agent 搜尋" --> KBN
  ENGN --> TRN
  PROF --> PS
  PROF --> ADP
  PS --> TRN
  ADP --> TRN
  INFRA -. "served name、port、profile id" .-> RT
圖表載入中…
層套件負責什麼可以 import
介面api/HTTP 路由、SSE、驗證ai、engine
應用ai/排程、編排、NAT 黏合、寫入閘門、建議追問、護理師輸入擷取、鑑別診斷 agent、模型呼叫紀錄engine、task_rules、llm、kb
知識庫kb.py、kb_import.pyKnowledgeBase.search 介面與 PostgreSQL 實作(全文檢索);匯入知識庫段落標準函式庫與 psycopg
任務engine/案件規則、寫入來源規則、與網頁模擬後端(mock)對齊的邏輯task_rules
任務task_rules/「這個 AI 任務的合格答案長什麼樣」只有標準函式庫
模型llm/提示詞、取樣參數、輸出修補、對數機率(logprob)讀取、信心評分task_rules 與標準函式庫

這些依賴方向由 server/tests/unit/test_architecture.py 強制,沒有例外清單:

每一條掃描都另外用刻意放入的違規範例測試過,確認它確實抓得到。

4. 一句話的處理過程

救護人員說了一句話,從送出到畫面更新,中間經過哪些步驟?

sequenceDiagram
  participant U as 前端
  participant A as api/(介面)
  participant S as ai/ 排程與編排(應用)
  participant N as NAT(應用)
  participant V as vLLM
  participant L as llm/(模型)
  participant T as task_rules/(任務)
  participant E as engine/(任務)
  U->>A: POST /cases/{id}/messages
  A->>E: 建立訊息(寫入來源規則)
  A-->>U: 201(先回,AI 在背景跑)
  A-->>U: SSE message.created、assistant.started
  A->>S: schedule(case, message)
  S->>S: scheduler 依 P0/P1/P2/閒置排隊,空位數是 `settings.ai_concurrency`,預設取自 profile 的 max_streams(2),Spark 上的 inventory 設 3
  S->>S: sequencer 讓同一案件的 R1 句子依序處理
  S->>N: 呼叫擷取
  N->>V: 提示詞與取樣參數來自 profile
  V-->>N: 回覆(含 logprobs)
  N-->>S: 回覆文字
  S->>L: profile.reply_text 修補輸出
  S->>T: task_rules 檢查(範圍、詞彙、過敏接地)
  S->>L: choice_scoring 評分選項欄位
  S->>E: 寫入 record(人工或已確認的值優先)
  A-->>U: SSE record.updated、assistant.completed
  S->>S: P2 背景:主訴翻譯
圖表載入中…

圖中每一步都標了所屬的層。要注意修補與檢查的順序:先用模型層的 reply_text 把回覆整理成一個 JSON 物件,再交給任務層做嚴格檢查;評分與稽核紀錄則用沒有修補過的原始回覆(raw)。整個後端只有 llm/output.py 的 json_span 會取出 {…},其他地方再寫一份,會被架構測試擋下。鑑別診斷的 reply_text 之後還有一道格式修訂:repair_reply 只補 sources 陣列裡事實編號少一個的引號,其他不動。

背景工作與優先級

除了上面的即時句子,還有幾種背景工作。每一種都經同一個排程器(ai/scheduler.py),總空位是 settings.ai_concurrency;P2 同時最多佔 1 個空位,沒有 P0、P1 在等才啟動,P0 等不到空位時執行中的 P2 被取消(連線中斷,vLLM 會 abort)、之後重排。「閒置」只在沒有任何工作在跑、也沒有工作在等時才啟動,任何 P0、P1、P2 工作一到就取消它、排回隊伍;被取消 3 次,或等了設定的時間(鑑別診斷預設 60 秒,備忘整理 180 秒),就升成 P2。每一種在設定檔裡都有自己的取樣參數(第 6 章),輸出都先過 task_rules/ 的檢查才寫進案件。

任務優先級觸發輸出檢查開關(程式預設/Spark 上)
即時句子的分類、擷取(R1 救護員、R2 病患與旁人)P0每則救護端訊息record_checks.py、statement_checks.py、route_checks.py擷取固定開;路由分類器 TRIAIGE_ROUTE_CLASSIFIER 關/關
修正說話者的重新擷取、第二輪重評P1修正說話者;這一句要改的未複查欄位,現值的證據句不在這次判斷看過的句子裡同上同上
護理師輸入擷取P1護理師在檢傷畫面送出一段話nurse_note_checks.pyTRIAIGE_NURSE_EXTRACTION 開/開
主訴翻譯P2R1 寫入英文主訴之後complaint_guard.pyTRIAIGE_COMPLAINT_TRANSLATION 開/開
建議追問P2救護端對話停 8 秒;護理師端送出followup_checks.pyTRIAIGE_FOLLOWUP_SUGGESTIONS 開/開
急救責任醫院分級建議P2紀錄表欄位變動後 2 秒hospital_level_checks.py,臨床下限在 engine/hospital_level.pyTRIAIGE_HOSPITAL_LEVEL 關/開
醫師交接摘要的 AI 整理P2救護端送出、檢傷送出summary_checks.pyTRIAIGE_AI_SUMMARY 開/開
案件備忘整理閒置對話停 20 秒或送出memo_checks.pyTRIAIGE_MEMO_CONSOLIDATE 關/關
鑑別診斷 agent閒置案件送出(再送出也算)、檢傷送出differential_checks.pyTRIAIGE_DIFFERENTIALS 關/開

鑑別診斷與其他背景工作不同的地方:它是一個會呼叫工具的多步驟迴圈(最多搜尋 4 次,ai/nat_differential.py),不是單次呼叫;模型開推理。送出的交易裡先把狀態寫成 pending(mark_differentials),畫面在送出的那一刻就看得到新的一輪;每搜尋一次,步驟寫進 state["differentials"] 並經 SSE 事件 differentials.updated 只推給醫師(案件完成檢傷之後)。結果一律先過 check_differentials:事實編號要對得到案件欄位、段落 id 要是這一次搜尋回傳過的、不含機率或信心分數;任一份清單沒有合格項目就整次失敗,寫成 failed,不重試、沒有備援模型。一次執行的上限是設定檔的 differential_timeout_s(600 秒),實際約 1–3 分鐘。伺服器重新啟動時,上一個行程留下的 pending 會照實結算成失敗(fail_orphaned)。

5. 各關注點在哪裡

關注點位置層
系統提示詞llm/prompt_sets/ems_compact/(record.py、statement.py、classify.py、translate.py、consolidate.py、hospital_level.py、self_check.py),由 PromptSet 彙整模型
取樣參數(max_tokens、temperature)llm/profiles/nemotron_3_super/__init__.py 的 sampling;llm/nat_config.py 的 llm_entries 組成 NAT 的 LLM(擷取、路由、翻譯、備忘整理、摘要、追問、分級建議,加上開 Guardrails 自我檢查時的 rail、開鑑別診斷時的 differential_llm),ai/extractor.py 的 build_config_dict 呼叫它;每個 LLM 的 num_retries 都是 1模型
關閉推理的開關同一個設定檔(profile)裡 Sampling.extra_body模型
取出 JSONllm/output.py(json_span、strip_think_then_json_span),由 ModelProfile.reply_text 選用;嚴格解析在 task_rules/record_checks.py 的 parse_json_object模型/任務
對數機率讀取llm/wire.py 的 reply_logprobs模型
選項信心評分llm/choice_scoring.py模型
信心門檻(τ、cuts)llm/profiles/<id>/confidence_thresholds.json,Confidence.thresholds 指向它。Super 的已校準兩次(C2,2026-10-17、2026-10-18),但 τ 分不出差別,enabled=false;3.5 Super VL 的 τ 都是 null。見 eval/results/choice-confidence-2026-10-18/summary.md、docs/specs/2026-10-14-inline-review/plan.md §4.4模型
生命徵象合理範圍、過敏「無」要有原文依據task_rules/record_checks.py任務
病患與旁人陳述的檢查、路由標籤與問題模式的檢查task_rules/statement_checks.py、task_rules/route_checks.py;欄位詞彙在 task_rules/vocabulary.py任務
主訴翻譯檢查task_rules/complaint_guard.py、task_rules/complaint_words.json任務
建議追問(救護員、護理師還該問什麼)的提示詞與檢查llm/prompt_sets/ems_compact/followup.py;回覆形狀與問句規則(繁體中文問句、不含治療與診斷字眼)在 task_rules/followup_checks.py模型/任務
建議追問的案件規則(欄位已填不問、去重、忽略過不再回來、每頻道最多 3 題開著、舊題結案)engine/followups.py;時機(對話停下來、送出)與 P2 排程在 ai/followups.py任務/應用
護理師輸入擷取(檢傷畫面打的一段話 → 檢傷欄位提案)的提示詞與檢查llm/prompt_sets/ems_compact/nurse_note.py;白名單欄位、值的範圍、數值要在原文、無過敏要有依據在 task_rules/nurse_note_checks.py模型/任務
護理師輸入擷取的案件規則(提案掛在護理師那則訊息、不蓋掉護理師已填的值、套用時再檢查一次)engine/nurse_notes.py、engine/triage.py 的 apply_proposals;呼叫在 ai/nurse_notes.py(P1)任務/應用
寫入規則(人工值優先、supersedes、拒絕記憶)engine/record.py、field_rules.py、review.py任務
醫師交接摘要的 AI 整理:提示詞與檢查、狀態llm/prompt_sets/ems_compact/summary.py(取樣 sampling["summary"],逾時 60 秒);來源存在且有內容、數字出自引用欄位、不含診斷與處置用語、最多 6 句每句 160 字在 task_rules/summary_checks.py;狀態(aiSummary,ready 或 failed)與稽核在 engine/ai_summary.py;觸發與排程(P2)在 ai/service.py 的 _summarize模型/任務/應用
模型呼叫紀錄(給營運儀表板)ai/call_log.py:每一次模型請求(含被擠掉後重跑、agent 的每一輪)一行 JSON,TRIAIGE_LLM_CALL_LOG 指到檔案才記;背景執行緒寫入,20 MB × 3 個檔應用
知識庫檢索kb.py(KnowledgeBase.search、PgKnowledgeBase)、ai/kb_binding.py(NAT 設定只能放可序列化的值,所以放一把鑰匙,檢索器登記在這裡)、kb_import.py(匯入)、server/scripts/export_kb.py(匯出);來源見下一節應用
急救責任醫院分級建議:輸入欄位、臨床下限(到院前檢傷 1–2 級、危急、疑似 STEMI 不建議一般)、寫入規則engine/hospital_level.py(mock 在 web/src/mock/engine/hospital-level.ts,mock_parity.json 的 hospitalLevel 比對);回覆形狀與引用已填欄位的檢查在 task_rules/hospital_level_checks.py任務
TTASengine/ttas.py、engine/ems_ttas.py任務
稽核身分(AI 名稱與版本)engine/state.py 的預設值是中性的 unset;CaseStore 必須傳入 ai_actor(store.py),app.py 依 settings.llm_model 與提示詞版本組出應用
逾時與併發config.py:預設值取自 profile.serving,環境變數仍可覆蓋模型(值)/應用(機制)
Guardrailsai/workflow.yml 只放 functions、middleware、workflow;政策由 ai/extractor.py 依旗標補上應用
部署手冊infra/playbooks/deploy-nemotron-super.yml。triaige_model_profile、vllm_served_names、llm_port 在 infra/inventory/group_vars/servers.yml,後端與 vLLM 的部署腳本(playbook)共用;vllm_stack 選 vLLM 服務哪一個模型基礎設施
醫師端鑑別診斷(agent)提示詞、搜尋工具定義與工具結果格式:llm/prompt_sets/ems_compact/differential.py;取樣(開推理)與逾時:設定檔的 differential、differential_timeout_s;agent 迴圈(NAT 串流函式 triaige_differential):ai/nat_differential.py;知識庫檢索(可換實作):kb.py;案件事實與狀態:engine/differential_facts.py、engine/differentials.py;輸出檢查:task_rules/differential_checks.py;觸發與排程(IDLE,送出時與檢傷送出後各一次,連續要求合併):ai/service.py 的 differentials_requested模型/應用/任務
評測eval/scripts/model_profile.py 提供 --profile,各正式任務的腳本都經由它讀取模型與取樣參數評測

知識庫的來源與檢索

鑑別診斷 agent 搜尋的是 PostgreSQL 的 kb_passages,共 3,714 段,來自三個來源。每一段有 id、來源(tintinalli、guidelines、ems_protocol)、語言、標題、出處字串(citation)與內文;畫面與輸出只用出處字串,不用內文。

來源(source)版本與出版收錄範圍段落數使用依據
tintinalli:Tintinalli's Emergency Medicine Manual第 7 版,McGraw-Hill,201222 篇 188 章中的 167 章;第 9 篇兒科(21 章、255 段)預設不匯出(export_kb.py --include-pediatric 才收),因為示範的病人都是成人1,745示範用。版權頁不允許未經同意存入檢索系統,授權未解決;只顯示來源、章節、頁碼,不顯示原文;全文與索引放 private/。正式使用要換成醫院自行取得授權的知識庫,因為 agent 只依賴 KnowledgeBase.search,換實作不必動 agent 與提示詞
guidelines:開放授權的臨床指引與共識(PMC)31 份,期刊年份 2013–2025只收 PMC 全文 JATS <license> 是 Creative Commons 的指引,非 CC 的拒收(eval/scripts/guidelines_rag.py fetch)。授權:CC BY 15 份、CC BY-NC 11 份、CC BY-NC-ND 5 份,所以 16 份只能非商業使用、不得改作後散布1,688全文在 private/guidelines/,repo 只放候選來源清單 eval/guidelines/sources.json;入選的 31 份(PMCID、簡稱、授權)在 private/guidelines/fetched.json。出處格式「簡稱(期刊 年份,PMCID,授權),§ 章節」
ems_protocol:臺北市政府緊急救護技術員到院前緊急救護作業程序手冊第六版(封面 115 年 4 月),臺北市政府消防局醫療顧問委員會總編輯,臺北市政府消防局出版,PDF 140 頁全書(通用程序 G、非外傷與外傷各程序 M、T、特殊情境 S、技術 A、附錄與前言),依標題與編號步驟切段281臺北市的作業程序,不是全國規範。版權屬臺北市政府消防局,「欲參採或引用請事先書面申請」,沒有公開授權,也還沒有取得書面同意,授權問題未解決(docs/team-discussion-todo.md A16)。使用者 2026-10-20 決定,使用手冊裡的切點數字(呼吸 ≥ 30、血糖 < 60 或 ≥ 500)為生命徵象標色不算引用(docs/references/vital-tint-thresholds.md)

31 份指引依主題:胸痛 3(ANMCO-SIMEU 2016、Brazilian Chest Pain ED 2025、SIRM-SIC 2025);呼吸、給氧與肺栓塞 5(GOLD 2023、BTS Oxygen 2017、TSANZ Oxygen 2015、BTS Outpatient PE 2018、PERT Consortium PE 2019);心衰竭 1(KSHF 2023);敗血症與感染 2(J-SSCG 2020、SEOM Febrile Neutropenia 2018);神經 3(ED Delirium GRADE 2026、EUSEM Syncope 2024、Acute Vertigo ED Consensus 2024);消化與腹部 9(WSES 腸繫膜缺血/消化性潰瘍/胰臟炎/膽囊炎/高齡膽囊炎、ESGE 兩份出血、Asia-Pacific 上消化道出血、BSG 腹水);內分泌與代謝 4(高血糖危象共識 2024、JBDS HHS 2023、ESE-ES 腎上腺功能不全 2024、ED 高血鉀共識 2021);創傷 4(歐洲創傷出血 2023、WSES 高齡創傷 2023、WSES-AAST 胸部創傷 2025、Scandinavian 頭部外傷 2013)。

建立與匯入的流程:先在開發機用 eval/scripts/tintinalli_rag.py build、guidelines_rag.py build、ems_protocol_rag.py build 各建一份 SQLite FTS5 索引,再由 server/scripts/export_kb.py 合成 private/kb/kb_passages.jsonl(不進 git,三個來源的 id 不能重複),部署時加 -e triaige_kb_import=true,由 python -m triaige_server.kb_import 在後端容器內匯入(可重複執行:檔案裡有的來源先整批刪除再寫入)。

檢索是 PostgreSQL 全文檢索,沒有向量:英文查詢用 tsv(english 設定,標題權重 A、內文 B,查詢字是 OR,五個字母以上的字當前綴),查詢字串含中文字就用 tsv_zh(中文每個連續字串切成相鄰兩字,simple 設定,匯入與查詢用同一個 cjk_terms),依 ts_rank_cd 排序,每次回傳 1–8 段,可限定來源(kb.py、migrations/003_kb_search.sql)。資料庫映像是 pgvector 版,但沒有建立擴充套件,embedding 欄位是空的。線上的檢索品質沒有量過;eval 裡量過的是臺北市手冊的 SQLite 索引(章節層級 recall@5 0.978、MRR 0.924,8 題標準答案是看過結果才更正的,eval/ems_protocol/README.md),那是另一份實作。

6. 模型適配層(llm/)

ModelProfile 是這條邊界的核心:後端要服務一個模型時需要知道的事,都收在這個物件裡。它是不可變的(frozen),建立時就檢查八個取樣角色是否齊全、上限是否為正數。

classDiagram
  class ModelProfile {
    id
    display_name
    reply_text(raw) str
    evidence
  }
  class Serving {
    api
    served_name
    default_base_url
    timeout_s
    max_streams
    recipe
  }
  class Sampling {
    max_tokens
    temperature
    extra_body
  }
  class Confidence {
    top_logprobs
    thresholds
    fixtures
  }
  class PromptSet {
    id
    record
    statement
    classify
    translate
    consolidate
    hospital_level
    summary
    followup
    nurse_note
    differential
    self_check
  }
  ModelProfile --> Serving
  ModelProfile --> "8" Sampling : extraction、route、translate、rail、memo、summary、followup、level
  ModelProfile --> Sampling : differential(開推理,選填)
  ModelProfile --> Confidence
  ModelProfile --> PromptSet
圖表載入中…

應用層有三條固定規則,不由設定檔決定:每個 LLM 的 num_retries 一律是 1,也就是總共只嘗試一次(NAT 的 LLM client 預設對 429 與 5xx 自動重試,總共 5 次;num_retries 不能是 0,清空 retry_on_status_codes 也不夠,所以降到 1,llm/nat_config.py),失敗就送出 assistant.failed;沒有備援模型;請求逾時與 asyncio.timeout 用同一個 settings.llm_timeout_s(摘要與鑑別診斷各有自己的較長逾時)。這三條都來自「失敗要明確,不默默降級」的原則。

現行設定檔:nemotron_3_super

欄位值依據
模型<帳號> Nemotron 3 Super 120B-A12B NVFP4,vLLM v0.31.0,DGX Spark 級主機infra/inventory/group_vars/servers.yml
服務名稱(served name)nemotron-3-super設定檔的 serving
base URLhttp://<主機名稱>:8000/v1同上
逾時15.0 秒(約 3 秒的 no-think 延遲留出餘裕)同上
同時串流數設定檔預設 2(舊版 vLLM v0.28.0 的第 3 條串流曾觸發 CUDA 錯誤);Spark 上 inventory 設 3(triaige_ai_concurrency),依 2026-10-19 在第一台 Spark 上的 soak:3 條沒有崩潰、逾時或 preemption,擷取 p95 3.89 秒設定檔的 max_streams、docs/specs/2026-10-13-ai-routing/soak-results.md
取樣擷取 300、路由 48、翻譯 96、護欄 8、備忘整理 600、分級建議 160、摘要 900(逾時 60 秒)、追問 400 個 token;temperature 0.0;enable_thinking=false。鑑別診斷自成一組:上限 8192 個 token(每次 LLM 呼叫,含推理)、enable_thinking=true 加 low_effort、一次執行 600 秒llm/profiles/nemotron_3_super/__init__.py
輸出修補json_span(取第一個 { 到最後一個 })llm/output.py 的 json_span
對數機率top_logprobs=5設定檔的 Confidence.top_logprobs
信心門檻已校準(c2-2026-10-18-compact-2026-10-14-context-2,τ 0.48846,likelihoodCuts [0.5, 0.02]),但 enabled=false:τ 把校準集的所有值都分成「高」,分不出差別llm/profiles/nemotron_3_super/confidence_thresholds.json、eval/results/choice-confidence-2026-10-18/summary.md
證據eval/README.md〈服務參數實測〉、〈R1 採用標準〉、〈X1 審查修正後的重新驗收〉;eval/results/serving-2026-10-12/、yardstick-2026-10-13/、r1-context-fix-2026-10-14/、choice-confidence-latency-2026-10-14/、choice-confidence-2026-10-17/;eval/complaint_zh/設定檔的 evidence

這些值由 server/tests/unit/test_model_profile.py 固定,設定檔裡列出的每個路徑也要存在。

候選設定檔:nemotron_3_5_super_vl(2026-10-18 評估過,沒有採用)

Nemotron 3.5 Super VL NVFP4,只用文字輸入。為了讓評估只比較模型本身,它的服務名稱是 nemotron-3.5-super-vl,取樣、token 上限、提示詞集、關閉推理的開關、json_span、top_logprobs=5、逾時與 2 條串流都和 Super 相同,由 test_model_profile.py 固定;它沒有 differential 取樣,所以和這個設定檔一起開 TRIAIGE_DIFFERENTIALS 會在啟動時失敗。信心門檻檔沒有校準(enabled=false,每個欄位的 tau 都是 null)。

評估的結果是比 Super 差:事先宣告的標準上,11 個結構化欄位 F1、主訴 F1、幻覺欄位三項都沒過;路由分類器的 record 召回從 0.952 掉到 0.857;逐句重播的重複填入率是 Super 的 5 倍(8.7% 對 1.6%)。它比較快(整段口述延遲中位數少 23%)。依使用者的授權條件(保留集不比 Super 差才切換),正式環境維持 Super,triaige_model_profile 沒有改(eval/results/nemotron-3-5-super-vl-2026-10-18/README.md)。這個設定檔留在程式裡,是下一次換模型時的範例;vLLM 啟動參數的出處、切換與回滾見 infra/README.md 的「評估 Nemotron 3.5 Super VL」。

第二個設定檔要填什麼

下面是示意,不是會上線的程式碼:

PROFILE = ModelProfile(
    id="<vendor_model_size_quant>",
    display_name="…, vLLM vX, DGX Spark",
    serving=Serving(api="openai_chat", served_name="<served-name>",
                    default_base_url="http://<主機名稱>:8000/v1",
                    timeout_s=<實測 p99 × 餘裕>, max_streams=<實測穩定串流數>,
                    recipe="infra/playbooks/deploy-<model>.yml"),
    prompts=ems_compact.PROMPT_SET,   # 先沿用;有保留集證據才分岔
    sampling={ "extraction": Sampling(max_tokens=..., extra_body={...}), ... },   # 八個角色都要有
    differential=Sampling(max_tokens=..., extra_body={...}),                      # 選填;要開鑑別診斷才需要
    reply_text=output.strip_think_then_json_span,   # 模型會輸出推理區塊時才需要
    confidence=Confidence(top_logprobs=5 或 None, thresholds=None,
                          fixtures="server/tests/fixtures/logprobs/<id>"),
    evidence=("eval/results/<date>-<id>-…",),
)

提示詞集與稽核版本

提示詞以提示詞集(prompt set)為單位,設定檔以 id 引用(ems_compact)。多個設定檔可以共用同一個提示詞集;只有保留集的證據顯示是模型讀錯時,才另外分出新的提示詞集,並提高 PROMPT_VERSION。稽核紀錄裡的 AI 身分是 name = 服務名稱、version = 提示詞版本,設定檔 id 只寫進啟動日誌與評測結果。

7. 檢查放哪一層

新增一條檢查時,怎麼決定它放在哪裡?先問一個問題:如果模型是完美的,這條檢查還需要嗎?

flowchart TD
  Q1{"換成完美的模型,<br/>這條檢查還需要嗎?"}
  Q1 -- 需要 --> TASK["任務層:task_rules/ 或 engine/<br/>影響畫面時要同步 web mock"]
  Q1 -- 不需要 --> Q2{"它在修補或量測<br/>這個模型或伺服器的輸出習慣嗎?"}
  Q2 -- 是 --> MODEL["模型層:profile.reply_text 或信心評分"]
  Q2 -- 否 --> RUNTIME["執行期:傳輸、排程、可用性<br/>留在 ai/"]
圖表載入中…

目前主要的檢查分類如下:

檢查位置層理由
取出第一個 { 到最後一個 } 之間的內容llm/output.py模型容許模型在 JSON 外多寫文字。已存的 Super 回覆都是乾淨的 JSON,所以對 Super 只是容錯
JSON 必須是物件task_rules/任務每個任務的輸出契約
未知欄位拒收、null 代表未陳述task_rules/record_checks.py、statement_checks.py任務輸出 schema
生命徵象範圍、sex 列舉、過敏狀態與藥名限制task_rules/record_checks.py、statement_checks.py任務生理與記錄限制
過敏「無」或「未知」要有原文依據task_rules/record_checks.py、statement_checks.py任務安全規則,任何模型都需要
路由標籤合法、問題模式回退 othertask_rules/route_checks.py任務分類器輸出契約
主訴翻譯:英文字詞白名單、數量、重複片語、中文數字/方位/身體部位比對task_rules/complaint_guard.py任務對任何翻譯者都成立的顯示規則
AI 整理的摘要:每句附來源、來源是給模型看過且有內容的事實、句子裡的數字出自引用欄位、不含診斷/治療/劑量用語、最多 6 句每句 160 字task_rules/summary_checks.py任務輸出契約與安全規則;不通過的句子丟掉,不修補
鑑別診斷:事實編號要對得到填了值的案件欄位;段落 id 只認這一次搜尋回傳過的,並換成出處字串;每組最多 5 項、不重複、不含機率或信心分數;任一組沒有合格項目就整次失敗task_rules/differential_checks.py任務輸出契約;書中原文不進輸出
事實編號少一個引號(f3"、"f3)llm/prompt_sets/ems_compact/differential.py 的 repair_reply模型這個模型偶爾寫壞;只修 sources 陣列裡「f 加數字」的項目,其他不動
急救責任醫院分級:只有 level、reason、fields 三個鍵,level 合法,理由 80 字內,引用的欄位必須是送給模型的已填欄位task_rules/hospital_level_checks.py任務輸出契約
臨床下限:到院前檢傷 1–2 級、危急、疑似 STEMI 時,一般改成中度engine/hospital_level.py任務臨床規則,與 mock 對齊
建議追問:繁體中文問句、5–40 字、結尾「?」、不含治療/劑量/診斷建議、欄位鍵在頻道詞彙內task_rules/followup_checks.py(欄位已填、重複、超過上限由 engine/followups.py 管)任務輸出契約與案件規則
護理師輸入擷取:只提案白名單欄位與合法的值,數值要在原文裡task_rules/nurse_note_checks.py(不蓋掉護理師已填的值在 engine/nurse_notes.py)任務輸出契約與案件規則
自我檢查護欄(Yes/No、max_tokens 8)llm/prompt_sets/ems_compact/self_check.py、設定檔的 rail 取樣模型依賴這個模型一個 token 就答
對數機率讀取、token 與回覆對不上時不給分數llm/wire.py、llm/choice_scoring.py模型依賴 vLLM/OpenAI 的對數機率格式與分詞
寫入閘門、前置路由(空白或標點略過)ai/gate.py、ai/route.py任務(留在 ai/)產品規則,確定性路由
人工值優先、supersedes、拒絕記憶engine/任務案件規則,與模擬後端對齊
逾時、HTTP 錯誤、NAT 回傳型別不明ai/extractor.py、llm/nat_config.py(num_retries = 1)執行期傳輸;失敗就失敗,不重試
連續失敗門檻(aiAvailable=false)、每案件 8 個待辦上限、排程空位ai/service.py、ai/scheduler.py執行期可用性與排程

系統裡沒有「JSON 壞掉就重試」或「改用備援模型」的程式,也不打算加。

8. 新增提示詞或檢查

9. 換模型操作手冊

換模型時要改的是 llm/ 裡新增的設定檔,加上 infra/ 裡那個模型自己的部署手冊;其他層不必改。

flowchart TD
  S1["1 可行性<br/>端點、對數機率、推理開關、記憶體、授權"] --> S2["2 部署手冊<br/>部署腳本、模板、group_vars;<br/>bench_serving 與 3 串流壓測決定 max_streams"]
  S2 --> S3["3 寫 profile 並登錄 registry"]
  S3 --> S4["4 不需模型的本機檢查"]
  S4 --> S5["5 錄對數機率測試資料"]
  S5 --> G{"6 宣告門檻後,<br/>保留集全部跑完<br/>達標?"}
  G -- 否 --> S7["7 在根因那一層修;<br/>不加個案字眼"] --> G
  G -- 是 --> S8["8 信心校準"]
  S8 --> S9["9 部署(--check --diff)"]
  S9 --> S10["10 記錄證據"]
圖表載入中…
  1. 可行性。 確認端點是 OpenAI 相容、logprobs 與 top_logprobs(含 bytes)能不能回、推理怎麼關、context 長度、記憶體(Super 現在約 95–103 GiB;後端與資料庫上限預設合計 2 GiB,見 triaige_server_mem_limit 與 triaige_db_mem_limit;借用的那台主機的資料庫是 2 GiB,因為知識庫索引要空間)、授權。資料來源見 docs/specs/2026-10-13-backend/research-<帳號>-stack.md 與 group_vars/servers.yml 的註解。
  2. 部署手冊。 新增 infra/playbooks/deploy-<model>.yml、模板與 group_vars 區塊,先 --check --diff,部署到 Spark,再跑 eval/scripts/bench_serving.py 與 3 條串流壓測,之後才決定 max_streams。
  3. 寫設定檔。 新增 llm/profiles/<id>/__init__.py,內容:身分與服務名稱、八個角色的 Sampling(max_tokens 取該角色在校準集上輸出 token 的 p99 × 1.5;要開鑑別診斷再加 differential 取樣)、reply_text、信心能力、逾時與串流數、prompts(先引用 ems_compact)、證據;然後在 llm/registry.py 登錄。
  4. 不用模型的本機檢查。 用 TRIAIGE_MODEL_PROFILE=<id> 跑後端的 pytest(StubLlm 與設定檔無關)、架構測試、設定檔契約測試(server/tests/unit/test_model_profile.py:每個角色都有取樣、路徑存在)、eval pytest、cd web && bun run check。
  5. 對數機率測試資料。 執行 server/scripts/record_logprobs_fixtures.py --profile <id>(--profile 預設是 nemotron_3_super),寫到 server/tests/fixtures/logprobs/<id>/。測試資料專屬的預期值要重新推導,性質測試不用改。
  6. 開跑前先宣告門檻(誠實規則)。以評測標準(yardstick) cb8bb62 為準,全部在保留集:基準是現行設定檔跑 3 次取平均;容忍度是 3 次的最大值減最小值,不設下限;候選模型跑 1 次。要跑的有:eval/scripts/run_extraction.py(整段口述)、eval/scripts/replay_backend.py(逐句,含正常與連發)、run_corrections.py 與 score_corrections.py、路由 T1/T3、過敏 T2、eval/complaint_zh、延遲 A/B(ab_latency.py、ab_logprobs.py)。一律在 lockf ~/.local/state/triaige-qa/super.lock 內執行,串流數不超過 max_streams,結果寫到 eval/results/<date>-<id>/。
  7. 失敗怎麼修。 在根本原因所在的那一層修正(2026-10-13 的使用者規則):模型讀錯了才改提示詞,而且只寫通則、只在校準集上調整、保留集只跑一次;切句或上下文的問題改擷取架構;合併的問題改 engine;標準答案有疑問就交給臨床組員。不能加只針對個案的字眼。
  8. 信心校準。 執行 eval/scripts/calibrate_choice_confidence.py:collect 在校準集與保留集上收集模型的回覆與 logprobs(要持有 super.lock),calibrate 寫出報告與該設定檔的門檻檔(C2)。達到 inline-review 計畫的 D7 目標、而且 τ 分得出差別之前維持 enabled=false:Super 在 2026-10-18 達到目標,但 τ 分不出差別,所以仍是 false。重新產生模擬後端用的副本,並跑漂移測試。
  9. 部署。 把 triaige_model_profile 指向新模型(infra/inventory/group_vars/servers.yml;服務名稱從 vllm_served_names 查,vLLM 的 vllm_stack 跟著它),--check --diff 後部署,再照 infra/README.md 跑部署後的煙霧測試,並重播兩個案例。回滾就是回到前一個設定檔 id 與前一個映像標籤。
  10. 記錄。 在 eval/README.md 加一節、更新設定檔的 evidence、更新本文第 6 章的表。

10. 部署與設定開關

flowchart LR
  subgraph Host["DGX Spark 主機"]
    CF["cloudflared(Cloudflare Tunnel)"]
    subgraph Docker
      SRV["triaige-server :8100<br/>記憶體上限 1536m"]
      DB[("PostgreSQL :5433<br/>記憶體上限 2g")]
      VLLM[("vLLM Nemotron 3 Super :8000")]
    end
  end
  CF --> SRV
  SRV --> DB
  SRV -- "TRIAIGE_LLM_BASE_URL" --> VLLM
圖表載入中…

部署由 infra/playbooks/deploy-triaige-server.yml、deploy-nemotron-super.yml、deploy-cloudflared.yml(Cloudflare Tunnel,對外網址 https://triaige-api.wayneh.tw)、deploy-dashboard.yml(維運儀表板)與 infra/playbooks/templates/triaige/docker-compose.yml.j2 負責,參數在 infra/inventory/group_vars/servers.yml,各主機自己的值在 infra/inventory/host_vars/。後端映像在主機上原生建置,標籤是部署當時的 git revision(12 碼)。對外服務跑在比賽現場借用的 ASUS GX10(infra/inventory/host.yml 裡的第二台主機;資料庫上限是 2g,因為知識庫的索引需要空間;Tailscale Funnel 在這台機器沒有取得公開 DNS 記錄,所以用 Cloudflare Tunnel);第一台 Spark 是評測與先前部署用的機器。正式環境的設定檔是 nemotron_3_super。

環境旗標(server/src/triaige_server/config.py):

旗標預設作用
TRIAIGE_MODEL_PROFILEnemotron_3_super選設定檔;未知值啟動失敗
TRIAIGE_R1_CONTEXT開擷取提示詞帶上前文(X1);關掉時 R1 只收那一句,改用不帶前文的提示詞
TRIAIGE_CHOICE_CONFIDENCE開要求回傳對數機率並對選項欄位評分;設定檔不支援對數機率時啟動失敗
TRIAIGE_COMPLAINT_TRANSLATION開啟用主訴英翻中
TRIAIGE_FOLLOWUP_SUGGESTIONS、TRIAIGE_FOLLOWUP_IDLE_S開、8 秒建議追問:救護端對話停下來 8 秒(擷取都結束)、護理師端送出時,背景請模型列出還該問的事,放進既有的建議清單(type = question,不彈出、不語音)。P2 最低優先級,被即時擷取擠掉後從頭再跑;失敗只記稽核 followup.skipped,不重試
TRIAIGE_NURSE_EXTRACTION開護理師輸入擷取:護理師在檢傷畫面打的一段話,一次模型呼叫(P1)擷取成檢傷欄位提案,掛在那則訊息的 proposedChanges(message.updated),護理師按「套用」才寫入;只提案白名單欄位、不蓋掉護理師已填的值。關掉時 nurse channel 只儲存並回覆固定文字
TRIAIGE_SELF_CHECK_OUTPUT關啟用 Yes/No 輸出護欄
TRIAIGE_ROUTE_CLASSIFIER關啟用路由分類器
TRIAIGE_MEMO_CONSOLIDATE關啟用備忘整理:對話停下來(TRIAIGE_MEMO_IDLE_S,預設 20 秒;還沒整理的句子不到 TRIAIGE_MEMO_MIN_LINES(4)句時,要等最舊的一句滿 TRIAIGE_MEMO_TAIL_S(60 秒))或送出時,一次讀一段話寫案件備忘;只在 AI 閒置時跑,任何工作一到就讓開,等了 TRIAIGE_MEMO_STALE_S(預設 180 秒)才升成一般背景工作;保留集通過之後才在 infra/ 打開
TRIAIGE_HOSPITAL_LEVEL關啟用急救責任醫院分級建議:紀錄表欄位變動後(TRIAIGE_HOSPITAL_LEVEL_DELAY_S,預設 2 秒內的變動合成一次),以 P2 背景工作讓模型讀已填欄位,寫 /triage/neededHospitalLevel(來源 ai,待複查);輸入沒變不再問、失敗只記稽核 hospital_level.failed、不重試
TRIAIGE_AI_SUMMARY開醫師交接摘要的 AI 整理:救護端送出與檢傷送出之後,背景(P2,被即時句子擠掉就重跑)把送出的紀錄表、備忘與檢傷評估整理成幾句附來源代號的繁體中文;提示詞與取樣參數在 llm/prompt_sets/ems_compact/summary.py 與 sampling["summary"],確定性檢查(來源存在、數字出自引用欄位、不含診斷與處置用語)在 task_rules/summary_checks.py,狀態與稽核(ai_summary.generated、ai_summary.failed)在 engine/ai_summary.py;結果放在 PhysicianSummary.aiSummary,規則版摘要照常,失敗時明說、不重試、沒有備援模型
TRIAIGE_DIFFERENTIALS關醫師端鑑別診斷:案件送出與檢傷送出後,背景 agent 搜尋 kb_passages 並整理常見與最致命各 5 項(開推理,約 1–3 分鐘:實測 70–110 秒,正式機 UAT 一次約 2.5 分鐘;一次執行上限取自設定檔,Super 是 600 秒);設定檔沒有 differential 取樣時啟動失敗;TRIAIGE_DIFFERENTIAL_TIMEOUT_S(預設取自設定檔)、TRIAIGE_DIFFERENTIAL_STALE_S(預設 60:等這麼久還沒輪到閒置時段就升成一般背景工作)。知識庫匯入見 infra/README.md「知識庫」
TRIAIGE_LLM_CALL_LOG不記錄指到一個檔案才記:每一次模型請求寫一行 JSON(任務、token 數、延遲、結果、輸入輸出節錄),給維運儀表板;ai/call_log.py
TRIAIGE_LLM_BASE_URL、TRIAIGE_LLM_MODEL、TRIAIGE_LLM_TIMEOUT_S、TRIAIGE_AI_CONCURRENCY取自設定檔(並行數預設 2)部署時可覆蓋

程式預設與 Spark 上不同的有四項(infra/inventory/group_vars/servers.yml,由 docker-compose.yml.j2 帶進容器):TRIAIGE_DIFFERENTIALS 開、TRIAIGE_HOSPITAL_LEVEL 開、TRIAIGE_AI_CONCURRENCY 3(2026-10-19 在第一台 Spark 上的 soak,docs/specs/2026-10-13-ai-routing/soak-results.md)、TRIAIGE_LLM_CALL_LOG 指到 /ops/llm-calls.jsonl。其餘旗標用上表的預設:TRIAIGE_SELF_CHECK_OUTPUT、TRIAIGE_ROUTE_CLASSIFIER、TRIAIGE_MEMO_CONSOLIDATE 關;TRIAIGE_R1_CONTEXT、TRIAIGE_CHOICE_CONFIDENCE、TRIAIGE_COMPLAINT_TRANSLATION、TRIAIGE_AI_SUMMARY、TRIAIGE_FOLLOWUP_SUGGESTIONS、TRIAIGE_NURSE_EXTRACTION 開。

11. 證據索引

12. 已知限制