iT邦幫忙

2026 iThome 鐵人賽

DAY 25
0
佛心分享-SideProject30

為你自己蓋一座會複利的知識庫——WikiBrain系列 第 25 篇

# Day 25 - 用本機 HTTP 伺服器測模型來回

  • 分享至 

  • xImage
  •  

前言

這個產品的核心功能會呼叫別人的模型 API。那件事很難測,因為它慢、要錢,而且同樣的輸入不保證得到同樣的輸出。

我可以只測到呼叫之前為止。但編纂、對話、健檢就是那一段。

用真的 HTTP 伺服器當模型

測模型迴圈時,我起一台真的 HTTP 伺服器,把 base URL 指過去。整段編纂另有一支測試用 registerRunner 換掉 runner,那支不打模型,在行程裡把工具叫完。模型來回的那一段走下面這台伺服器:

const seen: any[] = [];
const fx = createServer(async (req, res) => {
  let body = ''; for await (const c of req) body += c;
  seen.push(JSON.parse(body));
  assert.equal(req.headers['x-api-key'], 'sk-ant-test');
  res.setHeader('content-type', 'application/json');
  if (seen.length === 1) res.end(JSON.stringify({ id: 'm1', type: 'message', role: 'assistant', model: 'claude-sonnet-5', stop_reason: 'tool_use', stop_sequence: null, content: [{ type: 'text', text: '先看目錄。' }, { type: 'tool_use', id: 'tu1', name: 'list_folder', input: { path: 'wiki' } }], usage: { input_tokens: 11, output_tokens: 6 } }));
  else res.end(JSON.stringify({ id: 'm2', type: 'message', role: 'assistant', model: 'claude-sonnet-5', stop_reason: 'end_turn', stop_sequence: null, content: [{ type: 'text', text: '完成' }], usage: { input_tokens: 25, output_tokens: 4 } }));
}).listen(0, '127.0.0.1');
await new Promise(r => fx.once('listening', r));
process.env.ANTHROPIC_BASE_URL = `http://127.0.0.1:${(fx.address() as AddressInfo).port}`;

出處:test/ingest.test.ts:125-135

listen(0) 讓作業系統給一個空的埠,所以測試之間不會互搶。第一次請求回一個工具呼叫、第二次回結束,這就是一次完整的 agent 迴圈,而且完全確定。

被測的那一邊長這樣,那些「憑空出現」的值都是從這裡傳進去的:

const events: string[] = [];
const r = await runAgent({ provider: 'anthropic', model: 'claude-sonnet-5', apiKey: 'sk-ant-test', system: 's', user: 'u', maxSteps: 5,
  tools: [{ name: 'list_folder', description: 'd', input_schema: { type: 'object', properties: { path: { type: 'string' } } } }],
  exec: async (name, input) => `${name}:${input.path}`, onEvent: e => events.push(e.type) });

出處:test/ingest.test.ts:137-140

apiKey 決定假伺服器會收到什麼 x-api-key,system: 's' 是那個一個字母的系統提示詞,exec 是假的工具實作(回傳 工具名:參數,所以斷言看得出工具有沒有被正確呼叫),onEvent 把事件型別收進 events 陣列。

比起 mock 套件,這個做法多了一個關鍵能力:seen 陣列裡是真的送出去的請求。所以斷言可以下在「我送給模型的東西對不對」,而不只是「我有沒有呼叫那個函式」:

const last = seen[1].messages.at(-1);
assert.equal(last.role, 'user'); assert.equal(last.content[0].type, 'tool_result'); assert.equal(last.content[0].tool_use_id, 'tu1'); assert.equal(last.content[0].content, 'list_folder:wiki');
assert.equal(seen[0].system, 's'); assert.equal(seen[0].tools[0].name, 'list_folder');

出處:test/ingest.test.ts:143-145

這幾行驗的是送出去的 Anthropic 請求:系統提示詞、工具名稱,以及工具結果被包成 tool_result 再送回去。之前我們已經講過,OpenAI 相容的那一套格式不同,用的是 role: 'tool'。下一支測試起另一台伺服器,斷言的就是那個欄位。

事件順序也是行為的一部分

assert.deepEqual(events, ['usage', 'text', 'tool', 'result', 'usage', 'text']);

出處:test/ingest.test.ts:142

進度面板是即時的,所以事件的種類與順序就是使用者看到的東西。少一個 usage 事件,費用統計就會少算;tool 跟 result 的順序反了,畫面上會先出現結果再出現呼叫。

這種斷言很脆,改一點實作就會紅。我還是留著,因為它蓋住的正是使用者會看到的那一層。

匯入也用同一招

之前我們已經講過,壓力測試不能打第三方網站。測試更嚴格:推上 main 或開 pull request 就會跑。

所以匯入的測試也起一台 fixture 伺服器:

if (req.url?.startsWith('/paper')) { res.setHeader('content-type', 'text/html; charset=utf-8'); res.end(PAPER_HTML); return; }
if (req.url?.startsWith('/spa')) { res.setHeader('content-type', 'text/html; charset=utf-8'); res.end('<html><head><title>SPA 課程</title></head><body><div id="root"></div><script>document.getElementById("root").innerHTML = "<h1>Claude Cowork 簡介</h1><p>" + "這段內容是瀏覽器端用 JavaScript 產生的,伺服器端抓不到。".repeat(12) + "</p>";</script></body></html>'); return; }
if (req.url?.startsWith('/thin')) { res.setHeader('content-type', 'text/html'); res.end('<html><head><title>App</title></head><body><div id="root"></div><script>/* SPA */</script></body></html>'); return; }
if (req.url?.startsWith('/challenge')) { res.setHeader('content-type', 'text/html'); res.end('<html><head><title>Just a moment...</title><meta name="citation_doi" content="10.1016/j.test.2020.1"></head><body><p>Enable JavaScript and cookies to continue</p></body></html>'); return; }
if (req.url?.startsWith('/crossref/')) {
  res.setHeader('content-type', 'application/json');
  res.end(JSON.stringify({ message: { title: ['Keyword Assignment in Medical Libraries'], author: [{ family: 'Chen', given: 'Amy' }, { family: 'Lin', given: 'Bo' }], issued: { 'date-parts': [[2024, 3]] }, 'container-title': ['Journal of the Medical Library Association'] } }));
  return;
}

出處:test/import.test.ts:39-47

/paper 是正常文章,HTML 在 PAPER_HTML。/spa 的正文由 JavaScript 寫進頁面。/thin 只有一個空的 div#root。/challenge 的標題是 Just a moment...,同時帶了 citation_doi。/crossref/ 回一份假的書目 JSON。

/challenge 要測的是兩個條件交會:頁面被擋下來,但有 DOI。測試斷言它被收成一篇論文、DOI 對得上,作者來自那份假的 Crossref。有書目就不開瀏覽器,是之前已經講過的匯入條件。

字數少於兩百才算內容太薄,是之前那條規則。/thin 比這條線更空。剛好卡在兩百字的頁,真實網站很少有,fixture 做得出來。

資料庫用真的

測試連的是真的 PostgreSQL。CI 走 GitHub Actions,公開 repo 用 GitHub 提供的 runner,不另外計費。workflow 在推上 main 或開 pull request 時跑,裡面直接開一個容器:

services:
  postgres:
    image: postgres:16-alpine
    env:
      POSTGRES_USER: wikibrain
      POSTGRES_PASSWORD: wikibrain
      POSTGRES_DB: wikibrain
    ports: ['5432:5432']
    options: >-
      --health-cmd "pg_isready -U wikibrain" --health-interval 5s --health-timeout 5s --health-retries 20

出處:.github/workflows/test.yml:9-18

理由是這個產品的邏輯有很大一部分就寫在 SQL 裡:樂觀鎖的 WHERE version = $5、待編纂的 NOT EXISTS、軟刪除復活的 ON CONFLICT … WHERE deleted_at IS NOT NULL。換成替身等於把要測的東西換掉。

少了健康檢查,測試會在資料庫還沒接受連線的時候就開始跑,於是你得到一個每十次紅一次、重跑就好的 CI。那種測試會訓練你忽略紅燈。

這支匯入測試自己註冊一個帳號,結束時刪掉:

await auth.api.signUpEmail({ body: { email, password, name: 'im' } });
await pool.query(`UPDATE "user" SET "emailVerified" = true WHERE email = $1`, [email]);

出處:test/import.test.ts:56-57

第二行是繞過驗證信的方式。設定裡開了 requireEmailVerification,所以剛註冊的帳號登不進去;測試不去收信,直接把那個欄位改成 true。帳號資料裡只有這個欄位是直接改的,註冊和登入仍走正式那條。

不共用固定資料。共用的測試資料會長出隱性依賴:某支測試改了一頁,另一支莫名其妙紅了。

端對端用真的瀏覽器

最外層有一支 Playwright 腳本 test/e2e/m3_web.py,開真的瀏覽器。它做的是登入、改走「貼上文字」匯入、確認頁面出現在 raw/、再用 MCP 更新同一頁,看版本衝突時畫面會不會重載。匯入對話框預設停在貼網址,腳本改去貼文字。它沒有按編纂,註解寫著不要真的送出,避免花錢。

單元測試驗邏輯,這支驗畫面與 API 接起來之後還會不會動。它不進 CI。test.yml 的最後一步是 npm test,沒有這支腳本。它要本機的前後端都先跑起來,手動執行。

自動測不到的那一段

以上全部沒有測到一件事:模型實際上會不會照規則做。

假的模型端點回的是我寫好的腳本,所以它一定照規則做。真實的模型會不會在第四步先搜尋、會不會把佔位符當字面值抄進去、會不會在規則說「一事一頁」時真的只開一頁,這些沒辦法用斷言鎖住。

我的做法是把它當成觀察:每次改提示詞或工具描述,跑幾次真的編纂,讀產出的頁面跟工作紀錄。

還有一類東西也鎖不住:只有在第二個使用者同時在用的時候才會出現的行為。那需要的是一組會互相干擾的請求,也就是壓力測試在做的事。

小結

模型來回由本機一台 HTTP 伺服器回答。作業系統配一個空埠,第一次回工具呼叫,第二次回結束。斷言看的是送出去的那份請求:系統提示詞是 s,工具名叫 list_folder,工具結果帶著 tool_result 和 tu1 再送回去。事件順序鎖成 usage、text、tool、result、usage、text。

匯入同樣不打外面的網站。被擋下來但有 DOI 的頁收成論文,作者從假的 Crossref 來;/thin 是空的 div#root。CI 開 PostgreSQL 16,健康檢查通過才跑,樂觀鎖、待編纂、軟刪除復活都打在真的 SQL 上。測試自己註冊帳號,只把 emailVerified 改成 true。瀏覽器那支腳本登入、改走貼上文字、確認頁面在 raw/,版本衝突時畫面會重載。它沒有按編纂,也不在 npm test 裡。假伺服器一定照腳本回答,模型會不會照規則寫頁,要另外跑真的編纂,讀頁面和工作紀錄。


上一篇
Day 24 - 壓力測試找出無頭渲染的排隊瓶頸,改成有上限的公平池
下一篇
Day 26 - 開源選 AGPL,加上唯讀分享連結與 Obsidian 匯出
系列文
為你自己蓋一座會複利的知識庫——WikiBrain 共 28 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言