iT邦幫忙

2026 iThome 鐵人賽

DAY 3
0
ChatGPT & Codex

ChatGPT + Codex 打造高效能 AI 開發工作流系列 第 3

Day 03: 結構化輸出工作流 (Structured Output):利用 JSON Schema 實現穩定 API 對接

  • 分享至 

  • xImage
  •  

Day 03: 結構化輸出工作流 (Structured Output):利用 JSON Schema 實現穩定 API 對接 (Structured Output with JSON Schema)

本日核心價值 (Core Focus): 用 JSON Schema 把 AI 輸出鎖成 CodeChangePlan,在 Chat Completions 以 response_format 約束,再用 Pydantic 二次校驗;無效就 fail closed,絕不把散文送進 Codex。

概念說明與實戰情境 (Overview)
Prompt 寫「請回 JSON」不夠。模型仍會加 markdown 圍欄、漏欄位、把 risk 寫成中文。對接 C# API 或編排腳本時,一次壞 JSON 就會讓下游 Codex 改錯檔。解法是雙閘門:API 層用 json_schema 約束,程式層用 Pydantic 再驗一次。Schema 過不了就中止,不要「盡量解析」。輸出契約必須能被單元測試鎖死,這才能把 Day 01 的計畫階段變成穩定介面。

關鍵操作與範例 (Implementation & Example)

CodeChangePlan 是本系列的標準計畫物件。下游(Codex、CI、人工審核)只讀這些欄位:要動哪些檔、風險、測試指令、是否需要 PostgreSQL。files 必須是陣列;空陣列表示無變更,應直接退出而不是讓模型自由發揮。

欄位 型別 規則
summary string 一句話說明行為變更
blocked boolean true 時不准改檔,只准讀 questions
questions string[] blocked=false 時必須是空陣列
files object[] path / action / reasonaction ∈ create|modify|delete
risk string low | medium | highhigh 必須人審
test_command string 可直接在 repo 根目錄執行
sql_needed boolean truesql 不得為空
sql string PostgreSQL;不需要時為空字串

OpenAI Chat Completions 以 response_format.type = json_schema 啟用 Structured Output。模型名用 gpt-4o,可換成團隊現用模型。strict: true 時,Schema 必須宣告 additionalProperties: false,且 required 列出所有屬性。

import json
import os
import sys
from typing import Literal

from openai import OpenAI
from pydantic import BaseModel, Field, ValidationError, model_validator

MODEL = os.getenv("OPENAI_MODEL", "gpt-4o")  # 可換成團隊現用模型

PLAN_SCHEMA = {
    "type": "object",
    "additionalProperties": False,
    "required": [
        "summary",
        "blocked",
        "questions",
        "files",
        "risk",
        "test_command",
        "sql_needed",
        "sql",
    ],
    "properties": {
        "summary": {"type": "string"},
        "blocked": {"type": "boolean"},
        "questions": {"type": "array", "items": {"type": "string"}},
        "files": {
            "type": "array",
            "items": {
                "type": "object",
                "additionalProperties": False,
                "required": ["path", "action", "reason"],
                "properties": {
                    "path": {"type": "string"},
                    "action": {
                        "type": "string",
                        "enum": ["create", "modify", "delete"],
                    },
                    "reason": {"type": "string"},
                },
            },
        },
        "risk": {"type": "string", "enum": ["low", "medium", "high"]},
        "test_command": {"type": "string"},
        "sql_needed": {"type": "boolean"},
        "sql": {"type": "string"},
    },
}


class FileChange(BaseModel):
    path: str
    action: Literal["create", "modify", "delete"]
    reason: str


class CodeChangePlan(BaseModel):
    summary: str
    blocked: bool
    questions: list[str]
    files: list[FileChange]
    risk: Literal["low", "medium", "high"]
    test_command: str = Field(min_length=1)
    sql_needed: bool
    sql: str = ""

    @model_validator(mode="after")
    def fail_closed_invariants(self) -> "CodeChangePlan":
        if self.blocked and not self.questions:
            raise ValueError("blocked=true requires questions")
        if not self.blocked and self.questions:
            raise ValueError("blocked=false must use an empty questions array")
        if not self.blocked and not self.files:
            raise ValueError("unblocked plan must list files")
        if self.sql_needed and not self.sql.strip():
            raise ValueError("sql_needed=true requires PostgreSQL in sql")
        if not self.sql_needed and self.sql.strip():
            raise ValueError("sql_needed=false requires empty sql")
        return self


def request_plan(user_prompt: str) -> CodeChangePlan:
    client = OpenAI()
    resp = client.chat.completions.create(
        model=MODEL,
        temperature=0,
        messages=[
            {
                "role": "system",
                "content": (
                    "You output a CodeChangePlan. "
                    "If context is insufficient, set blocked=true."
                ),
            },
            {"role": "user", "content": user_prompt},
        ],
        response_format={
            "type": "json_schema",
            "json_schema": {
                "name": "code_change_plan",
                "strict": True,
                "schema": PLAN_SCHEMA,
            },
        },
    )
    raw = resp.choices[0].message.content
    if not raw:
        raise SystemExit("fail closed: empty model content")
    try:
        payload = json.loads(raw)
        return CodeChangePlan.model_validate(payload)
    except (json.JSONDecodeError, ValidationError) as exc:
        raise SystemExit(f"fail closed: {exc}") from exc


def gate(plan: CodeChangePlan) -> None:
    if plan.blocked:
        raise SystemExit("fail closed: blocked " + json.dumps(plan.questions))
    if plan.risk == "high":
        raise SystemExit("fail closed: high risk requires human review")


if __name__ == "__main__":
    prompt = sys.stdin.read()
    plan = request_plan(prompt)
    gate(plan)
    Path = __import__("pathlib").Path
    Path("plan.json").write_text(
        plan.model_dump_json(indent=2), encoding="utf-8"
    )
    print(plan.model_dump_json(indent=2))
    # 通過後才允許進入 Codex(Day 05)
    # subprocess.run(["codex", "exec", "--sandbox", "workspace-write",
    #                 f"Implement plan.json and run: {plan.test_command}"], check=True)

對 C# 後端,同一個 JSON 可反序列化成 record,作為內部 API 的 DTO。不要讓 Controller 直接吃模型散文。

public sealed record FileChange(string Path, string Action, string Reason);

public sealed record CodeChangePlan(
    string Summary,
    bool Blocked,
    IReadOnlyList<string> Questions,
    IReadOnlyList<FileChange> Files,
    string Risk,
    string TestCommand,
    bool SqlNeeded,
    string Sql
);

合法計畫範例(折扣計算,無 schema 變更):

{
  "summary": "Apply discountRate to order line subtotal in OrderService.GetTotal.",
  "blocked": false,
  "questions": [],
  "files": [
    {
      "path": "src/Orders/OrderService.cs",
      "action": "modify",
      "reason": "GetTotal currently ignores discountRate."
    }
  ],
  "risk": "low",
  "test_command": "dotnet test tests/Orders --filter GetTotal_applies_discount_to_subtotal",
  "sql_needed": false,
  "sql": ""
}

若折扣改為資料庫欄位,則 sql_needed=truesql 必須是可執行的 PostgreSQL(例如 ALTER TABLE orders ADD COLUMN discount_rate numeric(5,4) NOT NULL DEFAULT 0;)。Schema 無法表達「這段 SQL 能跑」,所以 test_command 仍要包含驗證步驟;SQL 正確性留給 Codex Sandbox 或 DBA 審核,但「有沒有 SQL」這個布林值必須由 Schema 鎖死。

同一份 PLAN_SCHEMA 也可交給 jsonschema 套件當第二道閘門,適合不想引入 Pydantic 的腳本環境。驗證失敗同樣 fail closed,行為必須與 Pydantic 分支一致,不能一邊嚴格、一邊寬鬆。

import jsonschema

def validate_with_jsonschema(payload: dict) -> None:
    try:
        jsonschema.validate(instance=payload, schema=PLAN_SCHEMA)
    except jsonschema.ValidationError as exc:
        raise SystemExit(f"fail closed: {exc.message}") from exc

plan.json 當內部 HTTP API 的 body:C# Minimal API 用 record 綁定,模型或綁定失敗回 400,不要進入 Codex 佇列。編排器只認三種出口:blocked(補 Context 後重送)、high(開票給人審)、low/medium(交給 Day 05 的 codex exec)。禁止第四種「先改一點再說」。空的 filesblocked=false 時視為無效計畫:那代表模型想聊天,不是想交付變更。

成本上也該把 Schema 當閘門。計畫階段用較小、較穩的模型即可;通過契約後才把 diff 與測試迴圈交給 Codex。壞 JSON 若仍往下送,你付的是 Sandbox 時間與錯誤 commit,不是省到 Token。欄位一旦進契約,就不要再從 summary 用自然語言回推意圖——那個回推路徑無法寫單元測試。

對接 C# 時,把 CodeChangePlan 當成一般 DTO:驗證失敗回 400,blocked=true 回 409 並把 questions 給呼叫端,risk=high 回 422 並開人工佇列。只有 200 才觸發 Codex。這樣前後端契約與 AI 契約是同一份 JSON Schema,OpenAPI 也可以直接引用,不必為「AI 專用」再養一套欄位。測試這層閘門時,準備三筆固定 payload:缺 filessql_needed=truesql 為空、risk 不在 enum。三筆都必須在進模型之後、進 Codex 之前被擋下;這比再寫一則「請務必輸出 JSON」的 Prompt 更有回歸價值。

實務上把驗證拆成兩層斷言,方便回歸。第一層只問「是不是合法 JSON、欄位與 enum 是否齊」。第二層問「跨欄位是否自洽」:blockedquestionssql_neededsql、未阻擋時 files 不得為空。第一層失敗代表模型或 response_format 設定有問題;第二層失敗代表 Prompt 的 Constraints 不夠硬。不要把兩層混成一個巨大 try/except,否則你無法判斷該改 Schema、改 Prompt,還是改閘門。通過兩層之後,才把 test_command 原樣交給下一階段的 Codex Sandbox;中間不要再改寫指令字串,以免計畫與實際驗證分家。閘門測試要進 CI:每次改 Schema 或 Pydantic 不變量,就重跑那三筆固定 payload。

注意事項與常見失敗 (Pitfalls)

  • 只用 json_object 不定 Schema: 模型仍可省略 files 或把 risk 寫成 。修法:使用 json_schema + strict: true,再用 Pydantic 做跨欄位不變量(sql_neededsql 連動)。
  • strict Schema 漏 required 或允許 additionalProperties API 會拒請求或 silently 丟掉多餘鍵。修法:每個 object 都設 additionalProperties: false,所有屬性列入 required;可選語意用空字串 / 空陣列,不要省略欄位。
  • 解析失敗還用 regex 救: 從 markdown 圍欄「挖」JSON 會讓壞契約混進管線。修法:JSONDecodeErrorValidationError 一律 SystemExit;不要 fallback 成自由文字。
  • high 風險自動進 Codex: Schema 只能限制 enum,不能替你做變更管理。修法:gate()highblocked 直接失敗,等人審。
  • 把 SQL 當註解塞進 summary 下游執行不到。修法:sql_neededsql 分欄;C# 端用 record 對應,禁止從 summary 再 parse。

本日總結 (Takeaways)

  • Structured Output 是 API 契約,不是 Prompt 禮貌用語;用 JSON Schema 鎖欄位與 enum。
  • 雙閘門:response_format.json_schema 約束模型,Pydantic / record 再驗不變量。
  • 無效 JSON、空內容、blockedhigh risk 全部 fail closed,不呼叫 Codex。
  • files[]risktest_commandsql_needed 是計畫的最小可行介面。
  • 模型名寫死可替換:預設 gpt-4o,用環境變數換成團隊現用模型。

明日預告 (Next)
明日進入 Day 04 高效 Prompt 模組化:打造可複用的 AI Coding Assistant 指令庫,把 Role / Constraints / Review 規則拆進 prompts/,用載入器組裝,不再複製貼上整份 Prompt。


上一篇
Day 02: Prompt Engineering 工作流:給開發者的精準 Prompt 調校與 Context 設計
系列文
ChatGPT + Codex 打造高效能 AI 開發工作流3
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言