技術文件查詢包含工具參數、工具結果與最終回答三種資料。模型以工具取得文件與設定,應用程式負責執行查詢,再將結果交回模型產生結構化回答。參數驗證、呼叫配對與回答驗證分別發生在不同階段,錯誤也有各自的診斷依據。

文件查詢程式提供一個讀取固定資料的工具,回答須包含文件版次、啟動命令與摘要。所有資料都在本機程式內,不會連到真實部署主機,也不執行文件中的命令。工具往返、權限檢查及結果驗證可在這份固定資料上獨立執行。

工具參數與回答 Schema

tools[].parameters 定義工具參數,text.format 定義模型最終文字回覆的結構。前者描述文件查詢接受的欄位,後者描述回答中的來源與命令。兩者可用於同一個 Responses 請求,驗證的資料仍各自獨立。

檢查層次本文的資料負責處理的地方
工具輸入document_idFunction schema 加上後端參數與權限驗證。
工具結果查詢成功、文件內容或可辨識的錯誤應用程式執行工具,依原 call_id 回填。
模型最終回答摘要、來源版次及啟動命令text.format 加上 Pydantic 解析。
業務一致性文件存在、已授權、版次與命令相符應用程式用查得的資料比對。

Schema 驗證資料結構,文件版次與啟動命令還需要和查詢結果比對。工具查詢完成後,後端會將結果送回下一輪;模型產生最終回答並通過驗證,才算取得可交付的輸出。

文件查詢與結構化回答程式

程式使用 Python 3.11 以上版本、OpenAI Python SDK 與 Pydantic 2。金鑰由環境變數 OPENAI_API_KEY 提供,預設模型為 gpt-6-astra,OPENAI_RESPONSES_MODEL 可指定其他相容模型。SDK 版本也應列入執行紀錄,供 parser 或回應型別問題比對。

python -m pip install -U openai "pydantic>=2,<3"
python -c "import openai, pydantic; print(openai.__version__, pydantic.__version__)"

本機資料有兩份文件,目前使用者的授權範圍只有 asgi-deploy。授權清單由後端提供,與 prompt 或模型傳入的工具參數無關。回答所列來源也會和成功查詢的文件比對,未查得的文件不能作為有效引用。

import json
import os
from openai import OpenAI
from pydantic import BaseModel, ConfigDict

client = OpenAI(timeout=45.0, max_retries=0)
MODEL = os.getenv("OPENAI_RESPONSES_MODEL", "gpt-6-astra")
DOCUMENTS = {
    "asgi-deploy": {
        "document_id": "asgi-deploy", "revision": "r3",
        "runtime": "ASGI",
        "command": "daphne -b 127.0.0.1 -p 8000 project.asgi:application",
    },
    "reverse-proxy": {
        "document_id": "reverse-proxy", "revision": "r2",
        "upstream": "127.0.0.1:8000", "websocket": True,
    },
}
ALLOWED_DOCUMENTS = frozenset({"asgi-deploy"})
TOOLS = [{
    "type": "function", "name": "lookup_document", "strict": True,
    "description": "依文件ID查詢目前使用者可讀取的技術文件與版次。",
    "parameters": {
        "type": "object",
        "properties": {"document_id": {"type": "string"}},
        "required": ["document_id"], "additionalProperties": False,
    },
}]


class DocumentReference(BaseModel):
    model_config = ConfigDict(extra="forbid")
    document_id: str
    revision: str


class DocumentAnswer(BaseModel):
    model_config = ConfigDict(extra="forbid")
    summary: str
    sources: list[DocumentReference]
    command: str | None


ANSWER_FORMAT = {
    "type": "json_schema", "name": "document_answer", "strict": True,
    "schema": DocumentAnswer.model_json_schema(),
}


def require_completed(response):
    if response.status != "completed":
        raise RuntimeError(
            f"生成未完成:{response.status};"
            f"{response.error or response.incomplete_details}"
        )
    for item in response.output:
        if item.type == "message":
            for part in item.content:
                if part.type == "refusal":
                    raise RuntimeError("模型拒絕回答:" + part.refusal)


def execute_tool(call, retrieved):
    if call.name != "lookup_document":
        return {"ok": False, "error": "UNKNOWN_TOOL"}
    try:
        args = json.loads(call.arguments)
        if not isinstance(args, dict) or set(args) != {"document_id"}:
            raise ValueError("參數必須只有 document_id")
        document_id = args["document_id"]
        if not isinstance(document_id, str) or not document_id:
            raise ValueError("document_id 必須是非空字串")
    except (json.JSONDecodeError, ValueError, TypeError):
        return {"ok": False, "error": "INVALID_ARGUMENTS"}
    if document_id not in ALLOWED_DOCUMENTS or document_id not in DOCUMENTS:
        return {"ok": False, "error": "DOCUMENT_NOT_AVAILABLE"}
    document = dict(DOCUMENTS[document_id])
    retrieved[document_id] = document
    return {"ok": True, "document": document}


def validate_answer(answer, retrieved):
    if not answer.sources:
        raise ValueError("回答必須引用已查詢的文件")
    referenced = set()
    for source in answer.sources:
        document = retrieved.get(source.document_id)
        if document is None or source.revision != document["revision"]:
            raise ValueError("來源文件或版次與查詢結果不符")
        referenced.add(source.document_id)
    expected = retrieved.get("asgi-deploy", {}).get("command")
    if "asgi-deploy" not in referenced or answer.command != expected:
        raise ValueError("啟動命令與已引用文件不符")


history = [{
    "role": "user",
    "content": "請查詢 asgi-deploy,摘要其執行方式,引用版次並列出啟動命令。",
}]
retrieved = {}
for turn in range(4):
    response = client.responses.create(
        model=MODEL, reasoning={"effort": "low"},
        instructions=(
            "使用繁體中文。先查文件,再根據工具結果回答。"
            "僅引用成功查得的文件與版次,不猜測未知設定。"
        ),
        input=history, tools=TOOLS,
        tool_choice="required" if turn == 0 else "auto",
        parallel_tool_calls=True,
        text={"format": ANSWER_FORMAT},
        max_output_tokens=4096, store=False,
    )
    require_completed(response)
    history.extend(response.output)
    calls = [item for item in response.output if item.type == "function_call"]
    if calls:
        if len({call.call_id for call in calls}) != len(calls):
            raise ValueError("同輪工具呼叫的 call_id 不可重複")
        for call in calls:
            result = execute_tool(call, retrieved)
            history.append({
                "type": "function_call_output", "call_id": call.call_id,
                "output": json.dumps(result, ensure_ascii=False),
            })
        continue
    if not response.output_text.strip():
        raise ValueError("沒有取得最終結構化回答")
    answer = DocumentAnswer.model_validate_json(response.output_text)
    validate_answer(answer, retrieved)
    print(answer.model_dump_json(indent=2))
    break
else:
    raise RuntimeError("工具回合超過應用程式上限,需檢查呼叫與工具結果")

流程在完成與拒絕狀態檢查後保存完整 output。有 function_call 的回合會執行並回填每個呼叫;沒有待處理工具時,程式解析最終 JSON 並驗證來源。四輪是這個應用程式設定的執行上限,用來終止持續查詢的迴圈,與 API 的固定限制無關。

第一輪的 required 要求模型查詢來源,後續使用 auto,允許模型在資料足夠時產生回答。若所有回合都設為 required,每輪仍會要求新的工具呼叫,流程便無法進入最終文字回答。

工具結果缺失與 call_id 配對

call_id 是 function_call 與 function_call_output 的配對識別,和 output item 的 id、response ID 各有不同用途。同名工具即使參數相同,不同呼叫仍有各自的 call_id,結果須對應原呼叫。

資料用途
response.id識別一輪模型回應,用於狀態接續與追查。
工具 item 的 id識別 output 內的項目,常見於串流事件的項目歸屬。
function_call.call_id回填 function_call_output 時使用的對應識別。
產品自己的 operation_id識別一次業務操作,用於冪等、查詢結果與恢復。

「No tool output found」與「No tool call found」表示呼叫和結果的關係有問題。診斷時可列出同輪所有 function_call 與回填結果,核對 call_id 是否一一對應,以及它們所屬的 response/conversation 鏈。只回填第一個結果,或混入其他對話的 call_id,都會破壞這份對應。

自管歷史以 history.extend(response.output) 保留同輪 reasoning、message 與呼叫項目,再加入全部結果。使用 previous_response_id 時,則以產生呼叫的 response ID 接續,input 僅提供新增結果,同一份完整歷史不必重送。請求形狀見對話的上下文管理及官方 Function calling。

DOCUMENT_NOT_AVAILABLE 表示這次查詢無法提供文件,仍會以原 call_id 回填為工具結果,模型可據此說明資料不足或要求補充條件。服務停機、使用者取消及權限變動等情況,則可由應用程式結束任務並保存當前狀態。

多工具與並行呼叫

程式允許 parallel tool calls,本機 handler 仍依序讀取固定資料。改用並行查詢時,各工作攜帶原 call_id,結果的完成順序可以不同,回填識別則保持不變。失敗工作也回傳自己的錯誤結果,與其他呼叫的成功結果分開配對。

有依賴的工具工作需要分回合處理,例如取得已授權文件 ID 後,下一輪才能使用該 ID 讀取內容。這類工作無法在同一回合取得彼此的結果。parallel_tool_calls=False 將同輪呼叫限制為零個或一個,是否必須呼叫工具仍由 tool_choice 控制。

Schema 請求錯誤與解析錯誤

Strict schema 不合法會在 API 層得到 400;回應返回後的 JSON 解析錯誤,則可能來自未完成輸出、拒絕或內容問題。這兩類錯誤分別發生在請求驗證與回應處理階段,修正位置也分別是 schema 定義與解析流程。

回答結構由 Pydantic 產生,經 text.format 傳入 API,回傳資料再由同一個模型類別驗證。物件的 extra="forbid" 會產生 additionalProperties: false,未設預設值的欄位全部列入 required。command: str | None 代表欄位必須存在,但值可為 null,可用來表示文件未提供命令的情況。

症狀應檢查的地方
400,指出 schema 中某個 object根物件、巢狀物件和 $defs 是否設定 additionalProperties;required 是否涵蓋欄位。
根層 anyOf 被拒絕把主要回答定義為 object,在欄位內表達支援的聯集;不要把整個回答做成根層 discriminated union。
空值使資料模型驗證失敗區分「欄位缺少」與「欄位存在且值為 null」,讓應用程式與 schema 採相同定義。
部分 JSON 造成 ValidationError先確認 response.status 與 incomplete_details,別把截斷資料送進完整模型驗證。
模型拒絕後解析失敗先處理 message.content 中的 refusal,不能預期拒絕文字也符合回答 schema。

JSON mode 約束 JSON 格式,Structured Outputs 則使用指定的 schema。SDK 的 responses.parse(text_format=DocumentAnswer) 仍需要未完成與拒絕的錯誤處理。SDK issue #2486 記錄了 openai v1.86.0 將未完成輸出交給 parser 後觸發 ValidationError 的情況,原始狀態與未完成原因應和解析例外一併保存。支援格式見Structured Outputs。

來源、版次與業務值驗證

Pydantic 驗證通過後,程式會比對來源是否為工具成功查得的文件、版次是否相同,以及啟動命令是否與文件一致。這些條件由後端直接比較。前端建立來源連結時,也可使用驗證後的文件資料,將可接受的網址範圍限制在已知來源。

摘要的語義品質需要固定問題與來源內容的測試資料。測試可指定必要事實、不得改動的值,以及資料不足時的呈現方式;Pydantic 驗證的是結構,無法驗證這些內容要求。

驗證失敗後可提供有限次數的修正機會,例如將「來源版次應為 r3」作為錯誤訊息。原回應與錯誤類型需要保留,已完成的業務操作則維持原結果,避免回答修正時再次觸發副作用。

模型重試與業務操作冪等

固定文件查詢是唯讀工具,重複執行不會修改外部資料。建立訂單、發信或變更設定則需要 operation_id 與執行紀錄。call_id 關聯一次 API 呼叫,operation_id 識別產品定義的同一次業務操作;重新生成得到新的 call_id 時,仍須依 operation_id 判斷該操作是否已執行。

中斷位置恢復時先確認應用程式的處理
呼叫尚未完整取得是否仍是 arguments 的片段不執行工具,保留 partial 與 response 識別。
呼叫已取得,工具尚未執行授權、參數、是否已有相同 operation建立執行紀錄後再執行。
工具已完成,模型回填失敗既有結果與相對應的 call_id使用已保存結果恢復,不重做業務副作用。
外部服務逾時,結果不明外部操作是否已提交依 operation_id 查詢或對帳,再決定是否重試。
使用者中途取消模型、工具與 Conversation 分別停在哪裡核對實際狀態,不把關閉網路連線當成所有工作已取消。

執行紀錄關聯產品任務、response、call、operation、狀態與結果。本地資料庫交易保護紀錄更新,外部提交則依賴服務自己的冪等或查詢能力;發生「操作已完成、回應遺失」時,既有結果可由相同的業務識別找回。

SDK issue #3561 使用 openai v2.45.0、gpt-4.1-mini 重現了取消串流後,在同一 Conversation 回填工具結果得到 400 的情況。這份回報有其版本與流程條件,實際診斷仍依保存的呼叫、對話鏈及回填結果判斷。相關狀態處理見串流、取消與恢復。

自訂工具、input_file 與 file_search

input_file 用於單次文件輸入,file_search 與 vector store 用於持續維護的文件庫檢索。需要依使用者權限取得目前版本、查詢自己的資料庫時,可由 lookup_document 這類自訂工具提供資料。各方式的差異在資料保存、更新與授權的責任範圍。

File search 是平台內建工具,由平台執行。include=["file_search_call.results"] 可取得檢索結果,回答的引用資訊則保存在 annotations。檢索結果數、文件處理狀態、是否命中資料,以及回答是否引用該資料,可分別用來診斷檢索與回答階段的問題。

Web search 也有獨立的工具項目與來源資訊。完整 output 與引用需保留在回應紀錄中,顯示文字僅是其中一部分。檔案輸入格式見圖片與檔案輸入,文件庫設定見File search。

非同步自訂工具允許模型在等待期間處理獨立工作,工具執行、授權與結果回填仍由應用程式負責。有依賴的查詢維持各自的回合,並行工作則依Async tool calling的流程保存待完成呼叫與原 call_id。

工具流程的錯誤測試與紀錄

固定資料可用來驗證未知工具、無效 JSON、未授權文件、重複 call_id、錯誤版次、缺少來源,以及結構合法但命令與文件不符等情況。這些測試直接檢查應用程式分支,可作為模型或 SDK 升級時的回歸基準。

錯誤紀錄包含 request/response ID、SDK 與模型版本、輸出狀態、工具呼叫及業務驗證結果。敏感參數與文件內容依資料保留政策另行管理。SDK parser 問題可用原始回應與最小重現確認版本差異,修正也應集中在共同的解析位置。

串流中斷與工作接續見串流、背景任務與失敗恢復;Chat Completions 的欄位映射見遷移對照。