技術文件查詢包含工具參數、工具結果與最終回答三種資料。模型以工具取得文件與設定,應用程式負責執行查詢,再將結果交回模型產生結構化回答。參數驗證、呼叫配對與回答驗證分別發生在不同階段,錯誤也有各自的診斷依據。
文件查詢程式提供一個讀取固定資料的工具,回答須包含文件版次、啟動命令與摘要。所有資料都在本機程式內,不會連到真實部署主機,也不執行文件中的命令。工具往返、權限檢查及結果驗證可在這份固定資料上獨立執行。
工具參數與回答 Schema
tools[].parameters 定義工具參數,text.format 定義模型最終文字回覆的結構。前者描述文件查詢接受的欄位,後者描述回答中的來源與命令。兩者可用於同一個 Responses 請求,驗證的資料仍各自獨立。
| 檢查層次 | 本文的資料 | 負責處理的地方 |
|---|---|---|
| 工具輸入 | document_id | Function 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 的欄位映射見遷移對照。
