問題:同一顆含 Softmax 的 tflite,正式站能編出 KL520 真 .nef, 本專案 worker 在 nef 階段撞 UnimplementedFeature [Softmax] exit 6。 根因:runtime ktc 的 eliminate_tail 是 no-op(閹割版、只印警告), 尾端 Softmax 未被移除;正式站產物實測為 logits 輸出(Softmax 已砍)。 修法(staging 實測驗證、與正式站產物權重段逐 byte 相同): - onnx/core.py:新增 remove_tail_softmax(),onnx2onnx_flow 後對所有 platform 移除 terminal Softmax(cut_nodes、不用 cut_types 避免誤砍 中間層下游)、多 output rewire 逐項驗證 fail-loud、onnx.checker、 removed_tail_softmax metadata - precheck.py:R3 誤擋收窄——刪 hw_not_support_col 欄位名誤命中、 op capture 改必須、抽不到具體 op 名改放行+warning(真 fail-open)、 刪自由文字猜 op fallback、加噪音 token 過濾 - tests:+16(29 passed;terminal/no-op/多 output/fail-open 全覆蓋) - docs:TDD §12、design-doc ADR-012、PRD 輸出語意變更(機率→logits) 行為變更:所有 platform NEF 輸出改 logits(與正式站一致)、 分類後處理需呼叫端自行補 softmax。 Review:兩輪(0C/3M→0C/0M);W3 docker integration 6/6 PASS (520+Softmax e2e 產真 .nef 799,844 bytes、720 回歸、precheck 放行)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
21 KiB
TDD 索引 — Kneron Model Converter 對外 API
作者:Architect Agent
狀態:Draft(Phase 0.8b 重寫 + 模組化)
最後更新:2026-05-16
auth 設計演進:本 TDD 反映 Phase 0.8b 拍板後的「目標狀態」。完整歷史見 visionA repo
docs/autoflow/04-architecture/adr/adr-015-server-to-server-api-key.mdv2.1 +adr-016-download-via-converter.mdv1.0。配套:
design-doc.md(架構決策)、../02-prd/PRD.md(需求)、../03-design/design-review.md(UX 回饋)。
變更歷程
| 日期 | 變更 | 作者 |
|---|---|---|
| 2026-04-25 | 初版 Draft 1.0(OAuth resource server + promote) | Architect Agent |
| 2026-04-25 | Multipart 上傳路徑改 visionA → converter 直傳;移除 FAA GET/HEAD | Architect Agent |
| 2026-05-16 | Phase 0.8b 重寫:visionA → converter 改 API key;新增 /result endpoint;OAuth resource server 章節砍除;模組化拆分為索引 + 子檔案 |
Architect Agent |
| 2026-07-06 | Worker 行為變更(M 級):onnx worker 對所有 platform 一律移除尾端 Softmax、輸出 logits(與正式站對齊、修 520 batch_compile exit 6);pre-check regex 收窄。新增 §12 | Architect Agent |
1. 文件結構
本 TDD 在 Phase 0.8b 重寫時拆分為模組化結構:
| 檔案 | 內容 | 目標讀者 |
|---|---|---|
TDD.md(本檔,索引) |
各章節摘要 + 子檔案連結 | 全部 |
auth.md |
API key middleware 設計 + 砍除 OAuth resource server 清單 + 保留 OAuth client | Backend |
api/api-jobs.md |
POST/GET /jobs、GET /jobs/:id 規格 |
Backend、Reviewer、Testing |
api/api-promote.md |
POST /jobs/:id/promote 規格 |
Backend、Reviewer、Testing |
api/api-result.md |
新增 GET /jobs/:id/result 規格 |
Backend、Reviewer、Testing |
database.md |
Redis schema + 索引 + Lua script | Backend |
infra.md |
Nginx / docker-compose / .env 變動 + 部署順序 | Backend、DevOps |
performance.md |
SLO + 延遲預算 + 負載測試 | Backend、Testing |
observability.md |
Log 格式 + 敏感資料保護 + 告警 | Backend |
security.md |
Trust boundary + Input validation + Auth security | 全部 |
design-doc.md |
架構決策 + ADR | 全部 |
| 本檔 §12 | Worker 轉檔管線行為變更(尾端 Softmax 移除 + pre-check regex 收窄,2026-07-06 M 級) | Backend、Reviewer、Testing |
2. Phase 0.8b 改動摘要
2.1 對外 auth 改 API key
- 砍
auth/middleware.js(OAuth resource server)+auth/jwks.js - 加
auth/apiKeyMiddleware.js - 4 個既有 endpoint 改掛
requireApiKey() - 新加
/resultendpoint 也用requireApiKey()
詳見 auth.md §1 + §3(砍除清單)。
2.2 新增 /result endpoint
GET /api/v1/jobs/:id/result- Streaming proxy NEF from MinIO → caller
- 4 種 4xx + 2 種 5xx 情境
- 雙路徑 NEF key 解析(新格式 + 舊格式向後相容)
- 2026-05-17 補充:rate limit(60 req/min,獨立 bucket)、Range header 防護(silently ignore)、audit log 8 個 action、Backend
source_filename寫入 acceptance criteria(§9-§14)
詳見 api/api-result.md。
2.3 保留不動
- Promote 流程(converter → FAA 仍走 OAuth client_credentials)
- Redis schema(除確認
source_filename欄位存在) - Worker、MinIO bucket、Nginx 結構
詳見 auth.md §2 + api/api-promote.md。
2.4 Config 變動
- 移除:
MEMBER_CENTER_ISSUER/MEMBER_CENTER_JWKS_URL/KNERON_CONVERTER_AUDIENCE/JWKS_*/JWT_CLOCK_TOLERANCE_SEC - 新增:
CONVERTER_API_KEY - 保留:
MEMBER_CENTER_TOKEN_URL/KNERON_CONVERTER_CLIENT_*/FILE_ACCESS_AGENT_*/OAUTH_*
詳見 infra.md §3。
3. 系統概述
3.1 角色
- Converter(本專案):Node.js Task Scheduler + Python Worker
- visionA-backend:Go 服務,Converter 對外 API 的唯一 caller
- Member Center(MC):OAuth authorization server — Phase 0.8b 後只給 Converter → FAA promote 用
- File Access Agent(FAA):NAS 邊界檔案閘道,single-tenant per instance
3.2 API 端點清單(Phase 0.8b 後)
| 方法 | 路徑 | Auth | 說明 | 規格 |
|---|---|---|---|---|
| GET | /health |
— | 健康檢查 | api/api-jobs.md §3 |
| POST | /api/v1/jobs |
API key | 建立 job | api/api-jobs.md §4 |
| GET | /api/v1/jobs |
API key | 列表 / Recovery | api/api-jobs.md §6 |
| GET | /api/v1/jobs/:id |
API key | 單一 job 狀態 | api/api-jobs.md §5 |
| POST | /api/v1/jobs/:id/promote |
API key | 搬檔到 FAA | api/api-promote.md |
| GET | /api/v1/jobs/:id/result |
API key | NEW stream NEF | api/api-result.md |
| POST | /api/v1/jobs/:id/download-tokens |
API key | Phase 2,回 501 | api/api-jobs.md §7 |
| DELETE | /api/v1/jobs/:id |
API key | Phase 2,回 501 | api/api-jobs.md §7 |
3.3 既有路徑(Phase 0.8b 不動)
| 方法 | 路徑 | 用途 |
|---|---|---|
| POST | /jobs (multipart) |
Web UI 既有上傳 |
| GET | /jobs/:id |
Web UI 狀態查詢 |
| GET | /jobs/:id/events (SSE) |
Web UI 進度 push |
| GET | /jobs/:id/download/:filename |
Web UI 下載 |
| GET | /queues/stats |
內部監控 |
這些走 internal vhost,不對外、不加 auth。
4. 技術堆疊(不變)
| 層級 | 選擇 |
|---|---|
| 後端框架 | Node.js 18 + Express 4 |
| 認證(對外) | API key(crypto.timingSafeEqual,Phase 0.8b 新) |
| 認證(promote) | OAuth client_credentials(jose / 自寫 fetch) |
| 資料庫 | Redis 7 |
| 物件儲存 | MinIO(Converter Bucket) |
| Worker | Python 3.10+ |
| 反向代理 | Nginx |
| 測試 | Jest |
詳見 design-doc.md §3.5。
5. 專案結構
apps/task-scheduler/
├── server.js ← Entry
├── src/
│ ├── config.js ← 集中讀 env(Phase 0.8b 改)
│ ├── redis.js ← Redis client
│ ├── auth/
│ │ ├── apiKeyMiddleware.js ← 【新】Phase 0.8b
│ │ ├── oauthClient.js ← 【保留】promote 用
│ │ ├── middleware.js ← 【砍】OAuth resource server
│ │ └── jwks.js ← 【砍】
│ ├── fileAccessAgent/
│ │ ├── client.js ← FAA HTTP client(保留)
│ │ └── errors.js ← 錯誤翻譯(保留)
│ ├── routes/
│ │ ├── legacy.js ← 既有 /jobs/* 路由
│ │ └── v1/
│ │ ├── index.js ← v1 router 組裝(要改 wire result + 換 auth middleware)
│ │ ├── jobs.js ← POST/GET(要改換 requireApiKey)
│ │ ├── promote.js ← POST promote(要改換 requireApiKey)
│ │ └── result.js ← 【新】Phase 0.8b
│ ├── services/
│ │ ├── jobService.js ← Job CRUD
│ │ └── doneListener.js ← Worker done event
│ ├── middleware/
│ │ ├── errorHandler.js ← 統一錯誤
│ │ └── requestId.js
│ └── utils/
│ └── logger.js
├── docs/openapi.yaml ← 要改 security scheme(OAuth → bearer/api_key)
├── .env.example ← 要改(見 infra.md §4)
├── README.md ← 要改 auth 章節
└── package.json
6. 實作任務拆分(給 Backend)
按 Autoflow 增量式開發規範,每個任務 = 一個可獨立 review 的單位。
Phase A — API key middleware + auth 切換(取代 OAuth)
| # | 任務 | 依賴 | 預估 | 驗收標準 |
|---|---|---|---|---|
| A1 | 新建 src/auth/apiKeyMiddleware.js |
— | 1d | unit test 全過:happy path、missing header、wrong key、constant-time、destroy socket、env 未設定 fail-fast |
| A2 | 修 src/config.js:新增 converter.apiKey、移除 OAuth resource server 相關 env、保留 promote 相關 |
— | 0.5d | config.test.js 過;啟動時 CONVERTER_API_KEY 未設只 warn(不 throw);OAuth resource server env 移除後 server 仍能啟動 |
| A3 | 修 src/routes/v1/index.js / jobs.js / promote.js:requireAuth(scope) → requireApiKey() |
A1, A2 | 0.5d | 既有 integration test 全過(401 行為改成 API key 模式驗);server 啟動正常 |
| A4 | 砍 src/auth/middleware.js + src/auth/jwks.js + 相關 test |
A3 | 0.5d | git rm + test runner 沒 broken import;search code base 沒有 reference 殘留 |
| A5 | 修 .env.example、docs/openapi.yaml、README.md:移 OAuth resource server 段、加 CONVERTER_API_KEY |
A4 | 0.5d | docs lint 過;OpenAPI security scheme 改 bearer / api_key |
| A6 | Integration test:API key 驗證 4 個情境(happy / missing / wrong / 503) | A1-A5 | 1d | 全部過;既有 jobs / promote integration test 仍過 |
Phase A 總工時:~4d
Phase B — /result endpoint
| # | 任務 | 依賴 | 預估 | 驗收標準 |
|---|---|---|---|---|
| B1 | 確認 jobService.createJob 寫入 source_filename 欄位(檢查既有 code、補上若缺) |
— | 0.5d | unit test 過;既有 job record 結構不破壞 |
| B2 | 新建 src/routes/v1/result.js(含 extractNefObjectKey、buildFilename、stream handler) |
B1, A1 | 1.5d | unit test 過:filename 各情境、雙路徑 key 解析、stream error / client close handling |
| B3 | Wire /result 到 src/routes/v1/index.js(含 requireApiKey + per-client rate limiter) |
B2 | 0.5d | server 啟動 + route table 正確;mergeParams 取 :id 通 |
| B4 | Integration test:/result 8 個情境(200 happy / 401 / 404 job / 404 result / 409 / 410 expired / 410 minio miss / 502) |
B2, B3 | 1d | 全部過 |
Phase B 總工時:~3.5d
任務排程建議
順序執行 A → B(Backend 單人):
- A1 + A2 可平行
- A3 等 A1 + A2
- A4 等 A3
- A5 等 A4
- A6 等 A5(整體 verify)
- B1 + B2 可平行(B1 簡單,B2 是主要工作)
- B3 等 B2
- B4 等 B3
預估總工時:~7.5 工作日(單人)。若可雙人並行,A 和 B 可分工,壓到 ~5d。
與 visionA 端的 dependency
| Backend 任務狀態 | visionA 端可以做什麼 |
|---|---|
| Phase A 完成、deploy stage | visionA 可以打 stage converter 的既有 endpoint 驗 API key 流程 |
| Phase B 完成、deploy stage | visionA 可以打 /result endpoint 驗 streaming |
| Phase A + B 都 deploy 完 | e2e 驗證(visionA repo commit 9e29ebf 已 ready) |
7. 測試策略
詳見 performance.md §7 + 各 api/*.md 的 test 章節。
7.1 Unit test 覆蓋率目標
apiKeyMiddleware:100%(少量 code、必須全 cover)result.js:90%- 既有 OAuth-related 改動:維持 ≥ 85%
7.2 Integration test 必跑
- API key 4 情境(happy / missing / wrong / 503)
- 既有 jobs / promote 在 API key 模式下仍過
/result8 情境(見api/api-result.md§7.1)
7.3 Manual stage e2e(部署後)
- curl 驗:
/health、POST /jobs、GET /jobs/:id、POST /promote、GET /result - visionA 端 e2e:完整 upload → poll → promote → download
8. 安全注意事項
詳見 security.md。重點:
CONVERTER_API_KEY不進 git / log / Slackconstant-time compare(防 timing attack)- Sec C1 暫緩(
.envhistory rewrite + secret rotation 在 Phase 1 ready 後做、含 CONVERTER_API_KEY) - Trust boundary:visionA 一旦被 compromise 可冒充任意 user_id(接受、與 OAuth 模型一致)
9. 風險與待確認
| # | 風險 | 影響 | 行動 |
|---|---|---|---|
| R1 | CONVERTER_API_KEY rotation 流程未自動化 | 低 | Phase 1 接受手動 |
| R2 | /result 高並發 stream 壓力 |
低 | NEF 通常小、visionA 是唯一 caller、QPS 可控 |
| R3 | Sec C1 暫緩(.env 進 git history) | 中 | Phase 1 ready 收尾後 rewrite |
| R4 | NEF 7 天過期後 client 重新轉檔 | 低 | API spec 已定義 410,visionA 端處理 |
| R5 | Phase 0.8b 部署期間「OAuth → API key」短暫不可用 | 低 | 既有 stage OAuth 從未跑通、不會有 regression |
10. 後續步驟
- 本 TDD 索引 + 子檔案送 PM / Design 三方互審
- 使用者審核
- Backend Agent 依 §6 的任務拆分增量開發
- Reviewer 每個任務把關
- Testing 整合測試 + e2e
- DevOps 部署(converter 先 + visionA 後)
11. 變更記錄
| 日期 | 版本 | 變更 | 作者 |
|---|---|---|---|
| 2026-04-25 | Draft 1.0 | 初版,Phase 1 完整規格(單檔 1390 行) | Architect Agent |
| 2026-04-25 | Draft 1.1 | Multipart 上傳路徑改 | Architect Agent |
| 2026-05-16 | Draft 2.0 | Phase 0.8b 重寫:API key + /result + 模組化拆分為索引 + 8 個子檔案 | Architect Agent |
12. Worker 轉檔管線行為變更(2026-07-06,M 級)
範圍界定:本 TDD 前 11 章聚焦 Task Scheduler 對外 API / auth 層(Phase 0.8b)。本章新增,記錄 Python Worker 轉檔管線 的一個行為變更 —— 與 API / auth 無關,但同屬本 repo 的架構決策、故收在同一份 TDD 索引,方便 Backend / Reviewer / Testing 讀。詳細實作插入點見 §12.2。
12.1 尾端 Softmax 自動移除(輸出 logits)
為什麼(背景)
- 問題:含尾端 Softmax 的分類模型(如 MobileNet classifier)轉 KL520 時,
batch_compile -T 520撞UnimplementedFeature: undefined CPU op [Softmax]、exit 6 失敗。 - 根因:runtime image(
/app/ktc/onnx_optimizer.py,kneronnxopt 版)的eliminate_tail是閹割版 no-op(container 內印WRANING: eliminate_tail is not available in current conda environment),所以即使onnx2onnx_flow(eliminate_tail=True)也砍不掉尾端 Softmax、Softmax 一路帶進 batch_compile 撞 520 不支援的 CPU op。 - 對齊正式站:正式站
converter.innovedus.com(另一套 code base)對同一顆 fixture 能編出真 520 .nef。staging 實測比對正式站 .nef:output =(1,3,1,1)定點量化 logits、無 Softmax,且權重段(wt0xc0530)與我們砍掉 Softmax 後產的 .nef 逐 byte 相同、compiler 版本字串完全相同(v0.9.1(6d7a863))→ 證明正式站就是「模型階段移除 Softmax、logits 落 host 後處理」,這是 Kneron 對 520 的標準做法。
決策(拍板)
onnx 階段(onnx2onnx_flow 之後、onnx.save 之前)對所有 platform 一律移除尾端 Softmax、輸出 logits,與正式站行為對齊。
- 不限 520:雖然只有 520 會因 Softmax exit 6,但為了「所有 platform 輸出一致 = logits」、與正式站對齊、避免 platform 分歧造成呼叫端困惑,決定所有 platform 統一移除。(若日後有「某 platform 需保留 Softmax」的需求,再走 platform 分支,屆時另開 ADR。)
- 觸發條件(實作對齊,2026-07-06 backend 實測修訂):只移除 terminal(graph 尾端、無下游節點)的 Softmax;中間層的 Softmax 不移除。理由見下方「怎麼做」的
cut_nodes說明——cut_types是 cut-from-node 語意,遇到中間層 Softmax 會把整段下游靜默切掉,故改用「terminal-only +cut_nodes」精準移除。移除後 logits 自動接成新 graph output。
怎麼做(實作方式,2026-07-06 backend 實測修訂)
- 插入點:
services/workers/onnx/core.py:36-37之間(onnx2onnx_flow(...)之後、onnx.save(model, output_path)之前)。 - 呼叫方式(backend 實作 + staging 實測可行):
(# 注意:runtime 的 ktc.onnx_optimizer 沒有 editor API(remove_nodes_with_types 等) # 必須改呼叫底層 tools.other.remove_nodes import sys sys.path.insert(0, "libs/ONNX_Convertor/optimizer_scripts") # 依 container 掛載點調整 from tools import other # 只挑「terminal(無下游節點)且 op_type == Softmax」的節點名,用 cut_nodes 精準移除 terminal_softmax = [ n.name for n in model.graph.node if n.op_type == "Softmax" and _is_terminal(n, model.graph) ] if terminal_softmax: other.remove_nodes(model.graph, cut_nodes=terminal_softmax)_is_terminal判斷該節點的 output 不是任何其他節點的 input,即 graph 尾端;實際 helper 命名以 backend 實作為準。) - 不要用
cut_types=["Softmax"](原 sketch 寫法,已棄用):cut_types是 cut-from-node 語意,遇到中間層 Softmax 會把其整段下游節點靜默切掉(危險)。改用cut_nodes(明確指定要移除的節點名)+ 只挑 terminal Softmax。對已驗證情境(fixture、尾端 Softmax)兩者行為逐 byte 等價,但cut_nodes+ terminal-only 在有中間層 Softmax 的模型上才安全。 - 不要用:
ktc.onnx_optimizer.remove_nodes_with_types(...)—— runtime image 的ktc.onnx_optimizer(kneronnxopt 版)沒有任何 editor API;ktc/onnx_optimizer_1_7.py雖存在但不可 import(hardcode/workspace路徑 + 依賴 container 沒有的onnx.optimizer)。 - 不要依賴
eliminate_tail=True做尾端清理(runtime 是 no-op)。 - job metadata 應標注「Softmax 已移除、需 host 端後處理」提醒呼叫端。
行為影響(重要,必須傳達給呼叫端)
- 所有 platform 的 NEF 輸出從「含 Softmax(機率)」變「logits」。
- 呼叫端(visionA-backend)若對轉檔結果做分類後處理(取 argmax 通常不受影響、但取「機率值」會受影響),需自行在 host 端補 softmax。
- ⚠️ 需通知 visionA 團隊:這是對外可觀察的輸出語意變更,即使 argmax 分類結果不變、機率數值會變。列為交付前跨團隊溝通項。
證據連結(個人層,per-branch)
- 實驗規劃(bug 反推、只讀 code):
.autoflow/05-implementation/tflite-520-experiment-plan-2026-07-06.md - staging 實測驗證(砍 Softmax → 520 產真 .nef、與正式站逐 byte 比對):
.autoflow/06-testing/reports/tflite-520-softmax-removal-verify-2026-07-06.md(H1/H2/H3 全綠、含正式站 .nef 比對閉環、backend 實作注意事項)
12.2 pre-check regex 收窄(設計修訂)
原設計 → 修訂
services/backends/precheck.py 的「不支援 op 早期失敗」pre-check,原設計是「掃到 marker 字樣(regex 命中)就擋」,實測發現會誤擋——hw_not_support_col 是欄位名、被 hw_not_support regex 誤命中,導致能編出真 .nef 的乾淨模型也可能被擋。
修訂為「訊號 + 具體 op 名稱才擋,抽不到 op 名稱時放行 + warning(fail-open)」:
| 項目 | 原設計 | 修訂後 |
|---|---|---|
hw_not_support_col(欄位名誤命中) |
會命中、造成誤擋 | 刪除 / 大幅收窄該 regex |
hw_not_support 的 op capture group |
optional(抽不到 op 名也擋) | 改必須(capture group 必抽到具體 op 名才算命中) |
| 抽不到具體 op 名稱時 | 擋(fail-closed) | 放行 + warning(真 fail-open) |
為什麼 fail-open
pre-check 的定位是「早期快速失敗、省 batch_compile 時間」的優化,不是授權邊界。誤擋(把能轉的擋掉)比漏擋(放行後 batch_compile 自己 fail)代價高。抽不到具體 op 名稱 = 訊號不明確 = 寧可放行讓後段真正的 batch_compile 判定。
證據連結
- rootcause 報告 §4a(pre-check 誤擋分析):見
.autoflow/06-testing/reports/tflite-520-softmax-removal-verify-2026-07-06.md§5(R3 誤擋風險實測數據點)+ 舊 rootcause 報告 §4a - 同 PR 收窄:本 regex 修訂與 §12.1 的 Softmax 移除同一個 PR 進,避免「Softmax 砍了但 pre-check 還誤擋」的半套狀態。
12.3 實作任務(給 Backend)
| # | 任務 | 檔案 | 驗收標準 |
|---|---|---|---|
| W1 | onnx worker 加「移除尾端 Softmax」步驟 | services/workers/onnx/core.py:36-37 |
用 tools.other.remove_nodes(graph, cut_nodes=<terminal Softmax 節點名清單>)(只移除 terminal Softmax、中間層不動;不可用 cut_types=["Softmax"],會誤砍中間層下游);砍後模型過 onnx.checker;520 e2e 不再 exit 6、產真 .nef;job metadata 標注 Softmax 已移除 |
| W2 | pre-check regex 收窄 | services/backends/precheck.py |
刪 / 收窄 hw_not_support_col;hw_not_support op capture group 改必須;抽不到 op 名改放行 + warning;既有測試不 broken |
| W3 | 回歸驗證 | — | 720/530/630/730 既有可轉模型仍能轉(輸出改 logits 但 .nef 有效);含 Softmax 的 520 模型能轉成 |
W1 + W2 同 PR。W3 交 Testing 做回歸(Prove-It:先寫「520+Softmax 應轉成」的 failing test → W1 修 → 轉綠)。
附註:本 TDD 從 1390 行單檔重組為 ~180 行索引 + 8 個子檔案。每個子檔案 < 500 行(單一職責),可獨立給 Backend / Reviewer / Testing 不同角色讀對應檔案、減少 context 負擔。§12(2026-07-06 新增)為 worker 轉檔管線行為變更,與 API/auth 層獨立。