JSON 格式正確,不代表 Agent 有權執行:四道輸出驗證閘門
結構化輸出(依固定欄位格式產生的模型回答)能讓答案更像表單;它不能替你確認內容為真,也不能替使用者取得退款權限。
先看一個客服情境
客服 AI 代理人(Agent)收到「這筆重複扣款,幫我退 25 元」。模型回傳一段 JSON(JavaScript Object Notation,JavaScript 物件表示法) {"intent":"refund","amount_cents":2500,"reason":"duplicate charge"}。這段文字是合法 JSON,欄位也都齊全;但它仍可能指向不存在的訂單、超過可退餘額,或來自沒有權限操作該帳戶的人。
JSON Schema(描述 JSON 欄位、型別與限制的規格)把資料寫成可驗證的合約。[11][13] OpenAI 與 Anthropic 都提供依 schema 約束輸出的功能。[18][14] OpenAI 文件區分 JSON mode(只要求輸出可解析 JSON 的模式)與 Structured Outputs(依 schema 約束回應);前者不保證符合指定 schema,並仍需處理不完整輸出等邊界情況。[10] 這能減少格式與欄位形狀不符,但不能證明交易事實或使用者權限。[18][11][16]
AI Engineering from Scratch 的一課把應用端防線拆成 JSON 解析、結構驗證、業務語意驗證、授權四關;這是可落地的工程分層,不是把「模型守約」當成安全機制。[2][3] 原 repo 測試也包含格式錯誤、缺少/額外欄位及型別陷阱等拒絕案例。[4] 這篇教學擴展的是伺服器端信任邊界(明確限制各身分可讀取的資料與可執行的動作);下方範例是本次另寫並實跑,不是原作者的程式。[1][2]
四道閘門各自能證明什麼
| 閘門 | 退款提案的檢查 | 通過後仍不能推論 |
|---|---|---|
| 1. 完整解析 | 回應已結束、JSON 可完整解析;拒絕截斷片段、重複 key 和非標準 NaN(非數字值)。[17] | 欄位正確或提案為真。 |
| 2. 結構 | 只接受明確列出的欄位與型別;金額是整數,不接受布林值。 | 訂單存在、金額合理。 |
| 3. 業務語意 | 對照可信的訂單記錄、可退餘額、幣別和作業規則。 | 呼叫者有權讀取或操作該訂單。 |
| 4. 授權與提交 | 用伺服器驗證的身分、帳戶範圍、權限與人工核准規則決定是否建立待審提案。 | 除非下游再檢查,前面的模型輸出不會變成授權憑證。 |
RFC 8259 的 JSON 數字文法不接受 NaN 或 Infinity,範例解析器因此在輸入邊界拒絕它們。[17] OWASP 將未充分驗證、清理就把模型輸出送往下游系統列為 Improper Output Handling;風險包含把輸出當成 SQL(資料庫查詢語言)、HTML(網頁標記語言)、檔案路徑或 shell(命令列殼層)指令直接使用。[15] OWASP GenAI Top 10 將 Improper Output Handling 和 Excessive Agency 列為不同風險類別,處理的邊界並不相同。[12]
Excessive Agency 的防線是在下游執行授權、採最小權限,並讓高影響操作經明確核准,而不是讓模型自行判斷「我可以做」。[16]
可執行範例:模型只能提出,不直接退款
以下程式只用 Python 標準庫。輸入、帳戶和訂單都是合成資料;解析器拒絕重複欄位、額外欄位、超長輸入及布林金額。授權函式另外使用伺服器提供的 session(已驗證工作階段)與 order(可信訂單紀錄),成功也只回傳「待人工審核」,不呼叫外部應用程式介面(API)。
import json
MAX_INPUT_BYTES = 4096
ALLOWED_INTENTS = {"refund"}
REQUIRED_FIELDS = {"intent", "amount_cents", "reason"}
def reject_constant(value):
# JSON 標準以外的 NaN、Infinity 一律拒絕。
raise ValueError("non-standard JSON number: " + value)
def unique_object(pairs):
# 同一個 key 出現兩次時拒絕,避免不同元件採用不同解讀。
result = {}
for key, value in pairs:
if key in result:
raise ValueError("duplicate JSON key: " + key)
result[key] = value
return result
def parse_proposal(raw):
# 先限制輸入大小,再解析完整 JSON;不從串流片段觸發動作。
if not isinstance(raw, str) or len(raw.encode("utf-8")) > MAX_INPUT_BYTES:
raise ValueError("input is missing or too large")
value = json.loads(
raw,
object_pairs_hook=unique_object,
parse_constant=reject_constant,
)
if type(value) is not dict or set(value) != REQUIRED_FIELDS:
raise ValueError("object fields do not match the contract")
if type(value["intent"]) is not str or value["intent"] not in ALLOWED_INTENTS:
raise ValueError("intent is not allowed")
# bool 是 int 的子類別;用 type(...) is int 拒絕 true/false 金額。
if type(value["amount_cents"]) is not int or not 1 <= value["amount_cents"] <= 100000:
raise ValueError("amount_cents is outside the demo contract")
if type(value["reason"]) is not str or not value["reason"].strip() or len(value["reason"]) > 240:
raise ValueError("reason must be 1-240 characters")
return value
def authorize_proposal(proposal, session, order):
# session 與 order 必須由伺服器端身分驗證及資料庫查詢提供,不能取自模型輸出。
if session.get("authenticated") is not True:
raise PermissionError("authentication required")
if session.get("account_id") != order.get("account_id"):
raise PermissionError("order is outside the authenticated account")
if session.get("can_request_refund") is not True:
raise PermissionError("refund request is not permitted")
if proposal["amount_cents"] > order.get("refundable_cents", 0):
raise ValueError("requested amount exceeds the refundable balance")
# 示範只產生待人工審核的提案,不呼叫退款 API,也不改正式資料。
return {"status": "requires_human_review", "amount_cents": proposal["amount_cents"]}
def expect_rejection(operation, error_type):
try:
operation()
except error_type:
return
raise AssertionError("expected rejection was not raised")
if __name__ == "__main__":
# 全部輸入與權限資料均為合成示例;這裡不連接外部服務。
proposal = parse_proposal(
'{"intent":"refund","amount_cents":2500,"reason":"duplicate charge"}'
)
session = {"authenticated": True, "account_id": "acct-demo", "can_request_refund": True}
order = {"account_id": "acct-demo", "refundable_cents": 3000}
result = authorize_proposal(proposal, session, order)
assert result == {"status": "requires_human_review", "amount_cents": 2500}
expect_rejection(lambda: parse_proposal("not json"), ValueError)
expect_rejection(
lambda: parse_proposal('{"intent":"refund","intent":"refund","amount_cents":2,"reason":"x"}'),
ValueError,
)
expect_rejection(
lambda: parse_proposal('{"intent":"refund","amount_cents":true,"reason":"x"}'),
ValueError,
)
expect_rejection(
lambda: parse_proposal('{"intent":"refund","amount_cents":2,"reason":"x","admin":true}'),
ValueError,
)
expect_rejection(
lambda: authorize_proposal(proposal, {"authenticated": True, "account_id": "other", "can_request_refund": True}, order),
PermissionError,
)
print("6 checks passed; no external action was performed")
本次實跑結果
在 ARM64 Linux(aarch64)、Python 3.13.5 執行 python3 structured_output_lab.py,語法編譯與六項 assert(斷言)/拒絕案例均通過,程式輸出為 6 checks passed; no external action was performed。這只驗證範例程式的預期行為,沒有連線支付服務,也不是生產環境安全認證。
把範例接到真實服務前
GBrain 文件中的具體信任邊界
GBrain README 明確提醒,來源授權與可見性篩選不能隔離本機檔案或共用資料庫憑證的呼叫者。[5] architecture 文件區分遠端來源授權與本機檔案等存取邊界。[6] 管理文件另說明管理者認證與用戶端授權責任。[7] 專案的授權單元測試和 HTTP grant(HTTP 請求授權)整合測試覆蓋特定路徑;它們是那些案例的證據,不是對所有存取方式的安全保證。[8][9]
這和模型輸出驗證是同一種工程思路:每一層只對自己能證明的事負責。schema 證明資料形狀;資料庫查詢證明當下記錄;授權程式證明這個主體在這個範圍可做這個動作。不要把其中一項成功擴張解讀成整條流程安全。[11][16]
我的判斷:把結構化輸出當成不可信 API 請求
最值得保留的不是某個模型供應商的輸出模式,而是「解析、結構、語意、授權」四關分離。模型可以幫忙把自然語言整理成提案;是否成立,要由可信資料與權限政策決定。[2][15][16]
對低風險的標籤或草稿,可在通過結構與語意檢查後直接使用。退款、寄信、刪除、發布、部署等有外部副作用的動作,應另設授權、冪等與核准閘門;若系統無法取得足以判斷的資料,拒絕或轉人工比猜測更安全。[15][16]
限制與風險
來源
- AI Engineering from Scratch README(commit 56c886c)
- Structured Output Is an Untrusted Contract
- Runnable validator source
- Runnable validator tests
- GBrain README(commit 7a066c2)
- GBrain brains and sources trust boundaries
- GBrain MCP authorization administration
- GBrain authorization boundary tests
- GBrain HTTP source grant integration test
- OpenAI Structured Outputs
- JSON Schema Validation draft 2020-12
- OWASP GenAI Security Project LLM Top 10
- JSON Schema: Creating your first schema
- Anthropic Structured Outputs
- OWASP LLM05: Improper Output Handling
- OWASP LLM06: Excessive Agency
- RFC 8259: The JavaScript Object Notation (JSON) Data Interchange Format
- Structured model outputs | OpenAI API