# GPT api ## json格式介紹 前面的key一定要是用雙引號`" "`標起來的String 後面的值可以是 | 類型 | 範例 | 說明 | | --- | --- | --- | | **字串** | `"王總"` | 必須用雙引號包住 | | **數字** | `42`、`3.14` | 不用引號,可以是整數或小數 | | **布林值** | `true`、`false` | 小寫,不能寫成 True/False | | **null** | `null` | 表示沒有值 | | **陣列** | `[1, 2, 3]`、`["Java", "SQL"]` | 用中括號,裡面可以放各種值 | | **物件** | `{ "school": "台大", "year": 2025 }` | 用大括號包住,裡面是 key-value 結構 | 例子 ```json { "name": "王總", "age": 30, "isStudent": false, "skills": ["Java", "Python", "SQL"], "profile": { "school": "台大", "major": "資訊工程" }, "messages": [ { "role": "user", "content": "你好" }, { "role": "assistant", "content": "你好!有什麼我可以幫忙的嗎?" } ] } ``` 說明 ```json { "name": "王總", // 字串 "age": 30, // 數字 "isStudent": false, // 布林值 "skills": [...], // 陣列(字串) "profile": {...}, // 巢狀物件 "messages": [ {...}, {...} ] // 陣列裡面是物件 } ``` 陣列裡面也可以放不同類別的值 雖然不是很常見 ```json { "data": [ "王總", // 字串 42, // 數字 true, // 布林值 null, // 空值 { "a": 1 }, // 物件 [1, 2, 3] // 陣列 ] } ``` ## send_message ```json POST /v1/chat/completions { "model": "gpt-4o", "stream": true, "temperature": 0.2, "top_p": 0.9, "max_tokens": 512, "seed": 1234, "response_format": {"type":"json_object"}, "tools": [{ "type":"function", "function":{ "name":"search_flights", "description":"查詢航班", "parameters":{ "type":"object", "properties":{ "from":{"type":"string"}, "to":{"type":"string"}, "date":{"type":"string"} }, "required":["from","to","date"] } } }], "tool_choice": "auto", "messages": [ {"role":"system","content":"你是一位旅遊助理"}, {"role":"user","content":"幫我找 5/1 從 TPE 飛 KIX 的機票"} ], "user": "uid_9876" } ``` 參數 | 型別/預設 | 重點 | - | - | - model* | string | 指定模型名稱(如 gpt-4o、gpt-4o-mini) messages* | array | 對話歷史,角色可為 system / user / assistant / tool temperature | number 0–2,預設 1 | 越高回覆越發散;0 最穩定 top_p | number 0–1,預設 1 | nucleus sampling;與 temperature 二選一調整為主 max_tokens | int | 限制「輸出」token 上限;不設則以模型剩餘 context 為限 stop | string 或 string[](≤4) | 遇到指定字元/字串立即停止生成 presence_penalty | number -2~2,預設 0 | + 促進新主題,- 厭惡離題 frequency_penalty | number -2~2,預設 0 | 抑制重複用詞 logit_bias | {token:int} | 對單一 token 機率微調(-100~100) seed | int | 固定隨機種子,讓同樣 prompt 可重現 tools | array | 宣告可呼叫的函式/外部工具 tool_choice | "auto" (預設) / "none" / {type:"function",function:{name:"foo"}} | 決定是否、或指定哪一支函式要被呼叫 response_format | {"type":"text"}(預設) / {"type":"json_object"} | 要求輸出純文字或有效 JSON stream | bool,預設 false | true 時用 SSE 邊產生邊推送 stream_options | {"include_usage":true}(選填) | 串流同時夾帶 usage 統計 user | string | 你的內部使用者 ID,利於濫用追蹤 n | int,預設 1 | 生成幾個 choices;stream 時僅支援 1 logprobs | object | 要求回傳 top-k 機率(僅支援末一 token) top_logprobs | int 1–20 | 與 logprobs 搭配,指定 k 值 ### 語氣調控(temperature & top_p) 先固定 top_p=1,只調 temperature ``` 0.0‒0.3 保守、幾乎不創新 0.4‒0.7 最自然、常用區間 0.8‒1.0 創意較多、字數可能變長 >1.0  天馬行空,注意邏輯錯誤機率上升 ``` top-p 把「所有候選 token」依機率由高到底排序,僅保留「累積機率總和 ≥ p」的那一小撮,再在其中用 temperature 洗牌抽籤。 =>這麼做等於先「切掉最冷門、長尾的字」,讓模型寫作時不會忽然跳出怪句子;而 temperature 則負責決定剩下那些字要多隨機。 **越小越容易出現奇怪的字** **EX:** ![image](https://hackmd.io/_uploads/SJzmnMWlxg.png) ``` ┌──────────────── ① presence_penalty ─────────────┐ │ 給「已出現過」的字詞 **一次性扣分**,鼓勵新主題 │ └───────────────────────────────────────────────┘ ↓ ┌──────────────── ② frequency_penalty ───────────┐ │ 按「已出現次數」乘權重扣分,抑制重複用語 │ └───────────────────────────────────────────────┘ ↓ ┌──────────────── ③ top-p (nucleus) ─────────────┐ │ 依機率排序,保留累積 ≥ p 的候選字(剪尾) │ └───────────────────────────────────────────────┘ ↓ ┌──────────────── ④ temperature ─────────────────┐ │ 對剩餘候選重新拉平/尖化機率,再隨機抽籤 │ └───────────────────────────────────────────────┘ ↓ 抽到下一個 token ``` | 參數 | 典型範圍 | 調高影響 | 建議用途 | |------|----------|----------|----------| | **presence_penalty** | `0 – 2` | ✔ 引入**全新**字詞、話題 | 腦力激盪、劇情走向 | | **frequency_penalty** | `0 – 2` | ✔ 減少重覆句、口頭禪 | 摘要、客服、技術文 | | **top_p** | `0.4 – 1` | ✔ 砍掉低機率的冷門字 | 設成1:不裁剪,等同「全池」<br>0.8~1.0 用於自由聊天<br>0.4~0.7 用於摘要、客服<br>控非法用語、縮短回答 | | **temperature** | `0 – 1.5` | ✔ 提升/降低整體亂度<br>(控制剩下候選字的隨機度) | 設成1:不變,機率照原分佈<br>0.0~0.3 嚴謹、一致<br>0.4~0.8 常態人類語氣<br>>1 創意 | ### 角色 | `role` 值 | 說話者 | 說明 | | --- | --- | --- | | `"system"` | 系統/開場設定 | 用來設定助手的行為風格或背景,像是「你是一個日語老師」 | | `"user"` | 使用者 | 代表你輸入給 GPT 的訊息 | | `"assistant"` | ChatGPT 回覆的內容 | 代表 GPT 的回答 | `role` 和 `content` 是用來構成「對話歷史」的基本單位,也就是一組組的訊息。每一則訊息都是一個物件。 **範例:一個完整的對話歷史** ```json { "model": "gpt-4", "messages": [ { "role": "system", "content": "你是一個有禮貌的 AI 助手。" }, { "role": "user", "content": "幫我寫一封請假信。" }, { "role": "assistant", "content": "好的,以下是一封請假信的範例..." } ] } ``` - 系統先設定 GPT 的角色風格。 - 使用者提出請求。 - GPT 依照指令給出回覆。 ### call function required `required` 是 **告訴模型「這些欄位是一定要填的」**,不填就不能執行這個 function。 - GPT 會努力產生包含 `required` 欄位的 `arguments` - 如果使用者說得不清楚,GPT 會「先問問題」來補全 `required` 的資訊再 call function ```json { "name": "create_calendar_event", "parameters": { "type": "object", "properties": { "title": { "type": "string" }, "start_time": { "type": "string" }, "end_time": { "type": "string" }, "location": { "type": "string" }, "description": { "type": "string" } }, "required": ["title", "start_time", "end_time"] } } ``` ### item與回傳array格式 `items` 是用來定義「**陣列(list)裡的每一項要長怎樣**」的。 ```json { "name": "add_tags_to_event", "description": "替事件加上多個標籤", "parameters": { "type": "object", "properties": { "event_id": { "type": "string", "description": "事件的唯一 ID" }, "tags": { "type": "array", "description": "要加入的標籤清單", "items": { "type": "string" } } }, "required": ["event_id", "tags"] } } ``` 回傳的樣子 ```json { "role": "assistant", "function_call": { "name": "add_tags_to_event", "arguments": { "event_id\": "123", "tags": ["會議", "重要", "線上"] } } } ``` #### 二維陣列 ```json { "name": "set_meeting_slots", "description": "設定多個會議時段(每段都有開始與結束)", "parameters": { "type": "object", "properties": { "slots": { "type": "array", "description": "一組會議時段,每個時段是一個 [start, end] 陣列", "items": { "type": "array", "items": { "type": "string" }, "minItems": 2, "maxItems": 2 } } }, "required": ["slots"] } } ``` 回傳的樣子 ```json { "role": "assistant", "function_call": { "name": "set_meeting_slots", "arguments": "{ \"slots\": [ [\"2025-04-11T09:00:00\", \"2025-04-11T10:00:00\"], [\"2025-04-11T13:00:00\", \"2025-04-11T14:00:00\"], [\"2025-04-11T16:00:00\", \"2025-04-11T17:00:00\"] ] }" } } ``` ## gpt_response ### 一般(非串流)成功回傳 gpt原始回傳的格式大概長這樣 所以才要用 `reply = response['choices'][0]` choices:陣列是為了支援 n>1 時返回多個候選答案。 finish_reason:判斷回答是否被截斷(length)或因違規被過濾(content_filter)。 usage:用來計算成本;同樣欄位在 Streaming 也會於最後一包送回。 ```json { "id": "chatcmpl-abc1234567890", "object": "chat.completion", "created": 1713806400, // UNIX 時戳 "model": "gpt-4o-mina-2025-04-15", "choices": [ { "index": 0, "message": { "role": "assistant", "content": "您好,這裡是回覆內容。", "tool_calls": null // 使用 function/tool-calling 時才會出現 }, "finish_reason": "stop" // 可能是 "stop" | "length" | "tool_calls" | "content_filter" } ], "usage": { // ← Input / Output token 在這裡 "prompt_tokens": 45, "completion_tokens": 92, "total_tokens": 137 }, "system_fingerprint": "fp_a1b2c3..." // 選填:若開啟 system_fingerprint=true } ``` ### 串流(stream:true)模式(不會比較貴) 「stream : true」= 把完整回應切成一小塊一小塊(chunk)立即送出,而不是等全部字都生成完才一次回傳。 每行以 data: 開頭 delta 只包含新增的第一個文字;你需要把所有 chunk 依序串起來才是完整回答。 ```json event: message data: { "id": "chatcmpl-abc1234567890", "object": "chat.completion.chunk", "created": 1713806401, "model": "gpt-4o-mina-2025-04-15", "choices": [ { "index": 0, "delta": { "role": "assistant", "content": "您好" }, "finish_reason": null } ] } event: message data: { "id": "chatcmpl-abc1234567890", "object": "chat.completion.chunk", ... "choices": [ { "index": 0, "delta": { "content": ",這裡是回覆內容。" }, "finish_reason": "stop" } ] } event: done data: { // 最終包帶 usage "usage": { "prompt_tokens": 45, "completion_tokens": 92, "total_tokens": 137 } } ``` ### Function / Tool Calling 回傳 當你在 messages 或 tools 內宣告函式定義,模型若決定呼叫函式,message 會改為下列結構: ```json { "id": "chatcmpl-abc1234567890", "object": "chat.completion", "created": 1713806400, // UNIX 時戳 "model": "gpt-4o-mina-2025-04-15", "choices": [ { "index": 0, "message": { "role": "assistant", "tool_calls": [ { "id": "call_01HE...", "type": "function", "function": { "name": "search_flights", "arguments": "{\"from\":\"TPE\",\"to\":\"KIX\",\"date\":\"2025-05-01\"}" } } ], "content": null }, "finish_reason": "stop" // 可能是 "stop" | "length" | "tool_calls" | "content_filter" } ], "usage": { // ← Input / Output token 在這裡 "prompt_tokens": 45, "completion_tokens": 92, "total_tokens": 137 }, "system_fingerprint": "fp_a1b2c3..." // 選填:若開啟 system_fingerprint=true } ``` ### 錯誤回傳格式 type | code | 說明 | - | - | - | invalid_request_error | context_length_exceeded | token 超過模型上限 insufficient_quota | insufficient_quota | 額度用完 rate_limit_error | rate_limit_exceeded | 達到 RPM / TPM 上限 server_error | 500 | 伺服器內部錯誤,可重試 ``` { "error": { "message": "You exceeded your quota...", "type": "insufficient_quota", "param": null, "code": "insufficient_quota" } } ``` ## swift api 交互 定義的數據結構 ```swift struct OpenAIRequest: Codable { let model: String let messages: [OpenAIMessage] let temperature: Float } struct OpenAIResponse: Codable { let id: String let object: String let created: Int let model: String let choices: [OpenAIResponseChoice] } ``` ```swift class ChatViewModel: ObservableObject { // 你的OpenAI API Key private let apiKey = "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" func sendMessageToGPT(messages: [ChatMessage]) async -> String? { // 將ChatMessage轉換為OpenAI格式 並且可以記住對話(是所有) let apiMessages = messages.map { message in OpenAIMessage( role: message.isMe ? "user" : "assistant", content: message.text ) } let requestBody = OpenAIRequest( model: "gpt-4.1-mini", messages: apiMessages, temperature: 0.7 ) // 編碼(將 Swift 對象轉換為 JSON): // 使用 JSONEncoder 將 OpenAIRequest 對象編碼成 JSON 數據 // 這個 JSON 數據會被發送給 OpenAI API guard let requestData = try? JSONEncoder().encode(requestBody) else { print("Failed to encode request") return nil } guard let url = URL(string: "https://api.openai.com/v1/chat/completions") else { print("Invalid URL") return nil } var request = URLRequest(url: url) request.httpMethod = "POST" request.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") request.addValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = requestData do { let (data, response) = try await URLSession.shared.data(for: request) guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { print("Error response: \(response)") if let errorString = String(data: data, encoding: .utf8) { print("Response body: \(errorString)") } return nil } // 解碼(將 API 返回的 JSON 轉換回 Swift 對象): // 使用 JSONDecoder 將 API 返回的 JSON 數據解碼成 OpenAIResponse 對象 let openAIResponse = try JSONDecoder().decode(OpenAIResponse.self, from: data) if let choice = openAIResponse.choices.first { return choice.message.content } else { return nil } } catch { print("Network error: \(error)") return nil } } } ``` 這些結構體都遵循 Codable 協議,這使得它們可以自動進行 JSON 編碼和解碼。當數據在網絡上傳輸時: 發送請求時:Swift對象 → JSON數據 接收響應時:JSON數據 → Swift對象 **encode原理解釋** apiMessages 會是一個 OpenAI 格式的消息數組,讓我用例子說明。 假設原始的 messages 數組包含以下對話: ```swift [ ChatMessage(text: "你好,我是小明", isMe: true), ChatMessage(text: "你好小明,很高興見到你", isMe: false), ChatMessage(text: "今天天氣真好", isMe: true) ] ``` 經過 map 轉換後,apiMessages 會變成這樣的格式: ```swift [ OpenAIMessage( role: "user", // isMe: true 所以是 user content: "你好,我是小明" ), OpenAIMessage( role: "assistant", // isMe: false 所以是 assistant content: "你好小明,很高興見到你" ), OpenAIMessage( role: "user", // isMe: true 所以是 user content: "今天天氣真好" ) ] ``` 當這個數據被編碼成 JSON 發送給 OpenAI API 時,會是這樣的格式: ```json { "model": "gpt-4.1-mini", "messages": [ { "role": "user", "content": "你好,我是小明" }, { "role": "assistant", "content": "你好小明,很高興見到你" }, { "role": "user", "content": "今天天氣真好" } ], "temperature": 0.7 } ``` # 專題紀錄 ## 費用 ### 測試結果 gpt4o-mini 線性代數-對角化的應用2024/12/10~2024/12/30, * 1000次 tokens用量:25000 費用:0.01美元(0.3台幣) ![截圖 2025-04-23 凌晨1.14.50](https://hackmd.io/_uploads/HkydOlLkxl.png) ![image](https://hackmd.io/_uploads/HJxY_eLyee.png) ![image](https://hackmd.io/_uploads/SkAR_g81xe.png) ### 省token的方法 | 手段 | 省 token 效果 | 備註 | |------|---------------|------| | **摘要舊訊息 / 只保留關鍵上下文** | ★★★★★ | 每少 1 K token ≈ 0.02 NTD(4o-mini input) | | **改用 4o-mini 或動態模型升級** | ★★★★☆ | 價格差 6–10 倍;省下最明顯 | | **降低溫度 / top-p,使回覆更短** | ★★★☆☆ | 若內容允許簡潔回應 | | **JSON 最小化 / 刪空白** | ★★☆☆☆ | 規模大時才顯著 | | **選擇標識縮寫(e.g. `"t"` 代 `"temperature"`)** | ★☆☆☆☆ | 只對巨量批次有感 | * 先做「動態裁切 + 自動摘要」——投入一天,可立即砍掉 50 %以上 input token。 * 上 GPT-4o-mini 為預設,再寫條件判斷升級 GPT-4o。 * 研究 Embedding-RAG,把大綱或書本內容放向量庫,真正需要時才塞進 prompt。 * 若之後有「批次生成題庫」需求,再導入 Batch API 或快取。 ## 資料結構 ```swift enum RepeatType: Hashable { case none case daily case weekly([Int]) // 0=Sunday, 1=Monday, ... case monthly([Int]) // 幾號 } ``` ```swift // Todo 任務資料結構 struct TodoTask: Identifiable { let id = UUID() // 唯一識別符 var title: String // 任務標題 var note: String // 任務備註 var color: Color // 任務顏色 var focusTime: Int // 專注時間(分鐘) var category: String // 任務類別 var isAllDay: Bool // 是否全天 var isCompleted: Bool // 是否完成 var repeatType: RepeatType // 重複類型 var startDate: Date // 任務開始日期(重複任務的起始日) var endDate: Date // 任務結束日期(重複任務的結束日) var createdAt: Date // 任務建立時間 } ``` ## procloudflare proxy 自架 (無firebase安全驗證) ### 限制時間 | 名稱 | 公式 & 來源 | 影響計費? | 跟串流的關係 | |------|-------------|-----------|--------------| | **CPU time (Execution Duration)** | Worker 實際佔用 V8 執行緒的毫秒數 | **是**。免費方案每次最多 10 ms,Standard 預設 30 s,可加購到 300 s。| 只在你解析/轉送每個 chunk 時稍微累加;等待下個 chunk 的空檔不算 | | **Request Duration (Wall-clock)** | 從請求進到節點開始,到最後一位元組送出為止 | 不計費;僅作為觀測指標。 | 串流會拉長這段時間,但不會拉高帳單 | ### 方案 ![image](https://hackmd.io/_uploads/HJ6l9gfxxg.png) #### 為何「串流」通常 更省 CPU time? **一次回整段:** Worker 先把 OpenAI 的整份回應讀完 → 暫存 → 再灌給前端。 單次 await apiRes.text() 會在 Worker 緩衝整段資料,CPU time 持續累加直到讀完。 **SSE 串流:** Worker 把 ReadableStream 直接 pass-through;V8 只在每個 chunk 到來時醒一下,寫入 edge socket 後馬上 await。 多數時間處於 idle,不計 CPU。 實測同一段 1 500 字答案,串流版常見 CPU time < 5 ms,而「非串流 + resp.text()」可能 20-40 ms(仍在免費額度內,但數字高很多)。 ### 教學 在專案資料夾內(例如 ~/Desktop/swift_app/cloudflare) ```bash npm init -y npm install wrangler # 瀏覽器跳出 OAuth ``` ![image](https://hackmd.io/_uploads/HyhoLYUkex.png =300x) ```bash npx wrangler login #登入 npm create cloudflare@latest gpt-proxy-api ``` 要選javascript 最後部署deploy ![image](https://hackmd.io/_uploads/B1ZbZavkge.png) ``` cd gpt-proxy-api ``` **編輯wrangler.jsonc** OPENAI_API_KEY 屬於 Secret,不能寫在檔案裡,下一步用指令加入。 ```jsonc { "name": "gpt-proxy-api", "main": "src/index.js", "compatibility_date": "2025-04-24", // 建議寫今天日期 "vars": { // 普通文字環境變數 "API_TOKEN": "my-secret-token" } } ``` **編輯src/index.js** ```javascript export default { async fetch(request, env) { // 1. 簡易白名單:驗證自訂 token(避免任何人呼叫) if (request.headers.get("x-api-token") !== env.API_TOKEN) { return new Response("Unauthorized", { status: 401 }); } // 2. 直接把 body 轉交給 OpenAI Chat Completions const openaiResp = await fetch( "https://api.openai.com/v1/chat/completions", { method: "POST", headers: { "Authorization": `Bearer ${env.OPENAI_API_KEY}`, "Content-Type": "application/json" }, body: request.body } ); // 3. 將 OpenAI 的回應原樣丟回 App(status、headers、body 都保留) return openaiResp; } }; ``` 加入秘密apikey ```bash npx wrangler secret put OPENAI_API_KEY ``` 大概長這樣type內是完整的`apikey` ``` [ { "name": "OPENAI_API_KEY", "type": "sk-pro..." } ] ``` 注意:這邊才輸入api的key ![截圖 2025-04-24 晚上10.35.46](https://hackmd.io/_uploads/SJ_Dnpv1ex.png) ```bash #查看已經輸入的secret key wrangler secret list ``` ```bash #正式部署 npx wrangler deploy 或是指定入口 npx wrangler deploy src/index.ts ``` 成功新增 http... 那行等等 proxyURL = URL 會用到 ![image](https://hackmd.io/_uploads/SJ3zqt8ygg.png) 在網頁確認 ![image](https://hackmd.io/_uploads/HkHZ66D1ge.png =500x) swift修改 ```swift class ChatViewModel: ObservableObject { // ❶ 刪掉存在 App 端的 OpenAI API Key // private let apiKey = "sk-..." // ← 移除 // ❷ 新增 Worker URL 與驗證 Token // ⬇︎ 修改 private let proxyURL = URL(string: "https://gpt-proxy-api.<帳號>.workers.dev")! private let proxyToken = "my-secret-token" func sendMessageToGPT(messages: [ChatMessage]) async -> String? { ... // ⬇︎ 修改:改呼叫 proxyURL var request = URLRequest(url: proxyURL) request.httpMethod = "POST" // ⬇︎ 修改:使用自訂 header,**不再帶 OpenAI Key** request.addValue(proxyToken, forHTTPHeaderField: "x-api-token") request.addValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = requestData ... } } ``` ### 安全問題 1. Cloudflare Worker 中 vars vs secret 差異: **重點:無論 vars 還是 secret,瀏覽器端都看不到。** Worker 是在 Cloudflare 邊緣執行的,程式碼(與環境變數)不會原封不動傳給用戶端。 類型 | 儲存方式 | 上線後能被誰看到? | 適用情境 -| - | - | - vars | 以純文字隨 Worker 一起部署 | 只有 Worker 執行環境(你的程式)能讀,使用者透過 HTTP 拿不到 | 不算機密,但也不想硬寫死在程式裡的常數 secret | 加密存放,Cloudflare 後端在執行時才解密注入 | 同上,只是連 Dashboard 也只顯示名稱,不顯示內容 | 真正機密——API Key、DB 密碼… 2. 不夠安全 **App 內要送 x-api-token** 你必須把 my-secret-token 硬寫在 App,或是從伺服器拿一次後緩存。 不論哪種,只要有人抓封包或逆向 App,就能抄到這組 token。 **Header 沒加密** Token 會隨每次請求出現在 HTTP header。雖然傳輸層有 HTTPS,但任何能在客戶端植入惡意程式/裝置 SSL Proxy 的人,都能擷取。 **無身分綁定** 任何拿到這組 token 的人,只要知道 Worker URL,就能重播請求。 類型 | 儲存方式 | 上線後能被誰看到? | 適用情境 -| - | - | - vars | 以純文字隨 Worker 一起部署 | 只有 Worker 執行環境(你的程式)能讀,使用者透過 HTTP 拿不到 | 不算機密,但也不想硬寫死在程式裡的常數 secret | 加密存放,Cloudflare 後端在執行時才解密注入 | 同上,只是連 Dashboard 也只顯示名稱,不顯示內容 | 真正機密——API Key、DB 密碼… ### stream模式要用要用半雙工(但在cloudflare不用理會 因為是送到他們主機上游) | 名稱 | 意義 | 與我們的串流聊天有何關聯 | |------|------|--------------------------| | **單工 (simplex)** | 先把整個 request 傳完,才能開始收 response;request 不能是 Stream | 傳統 `POST`;OpenAI 串流無法工作 | | **半雙工 (half-duplex)** | 仍然**必須**把 request 全部送完,之後才能讀 response,但 request body 可以是 `ReadableStream` | ✅ 目前 fetch 唯一允許的模式;我們只要把小小的 JSON 送出去,就開始等 SSE | |**全雙工 (full-duplex)** |request 還沒傳完就能邊收 response、甚至雙向持續推資料 | 規格裡還在討論;瀏覽器與 CF Workers 都尚未實作| OpenAI SSE 來說,我們只需要「上傳一個 JSON ⇒ 持續下載串流文字」,半雙工就夠了。 | 方向 | 你的 Worker 目前在做什麼 | 規格何時 **必須** `duplex:"half"` | 結論 | |------|------------------------|-----------------------------------|-------| | **上傳(request → OpenAI)** | 把一小段 **JSON**(`Uint8Array` / `ArrayBuffer`)一次送出去 | **只有**當 *request body* 是 `ReadableStream` 時才強制要宣告。<br>(標準稱為「可串流上傳」)| 依然合法。 | | **下載(OpenAI → 前端)** | `openaiResp.body` 本來就是 `ReadableStream`,<br>你直接把整個 `Response` return 回去 | **下載端** 不需要 `duplex`;Cloudflare 會自動把 stream pass-through 給瀏覽器 | 因此 SSE 可以順利一路流到 SwiftUI。 | ```javascript var index_default = { async fetch(request, env) { if (request.headers.get("x-api-token") !== env.API_TOKEN) { return new Response("Unauthorized", { status: 401 }); } const openaiResp = await fetch( "https://api.openai.com/v1/chat/completions", { method: "POST", headers: { "Authorization": `Bearer ${env.OPENAI_API_KEY}`, "Content-Type": "application/json" }, body: request.body } ); return openaiResp; } }; export { index_default as default }; ``` ## 系統架構與安全 ### App → 自家後端 → OpenAI API Key 不落地客戶端。 後端可統一加上 RAG(向量搜尋)或商業邏輯。 屬於 「BFF—Backend For Frontend」 模式,符合 Apple 對遠端程式碼執行的規範。 ### 向量資料庫 ⚡ File Search(OpenAI 內建)雖然貴,但零維運;大規模時再換 Pinecone/Qdrant。 團隊小、用量可控的情況下,先用 File Search 省 DevOps 時間。 ![image](https://hackmd.io/_uploads/rJr9OgIJgg.png) ### 內容過濾 把 moderations API 或 Assistants 策略模版串進 pipeline。 Apple 審核看重「違禁內容舉報機制」;這裡直接把 OpenAI 風險等級轉為前端遮罩。 ### 資料保留策略 在隱私權政策列「對話最長保存 X 天,逾期自動刪除」。 支援 GDPR/台灣個資法的 刪除請求 API,可用背景排程定期清理。 ### 提省錢三招 Batch API:離峰時段批量丟入,官方直接再折 50%(計畫內含)。 Cache hit:對重複提示用 cache=true;輸入、輸出各再省 75%。 分層路由:先用 nano 做「草稿」,再把需要高精度的少數請求升級到 GPT-4.1。 ## 串流與call function大致流程 tool_calls 內容會分片(delta)回來:name 通常在第一包,arguments 可能拆成好幾包。 當 finish_reason:"tool_calls" 出現,就代表「這次函式參數已完整」— 該去執行了。 執行完把結果以 role:"tool" 附加到 messages,再發下一次請求;模型會讀取結果並接著回答。 ``` (1) user prompt │ ▼ (2) POST /chat/completions ← 夾帶 tools 定義, stream:true │ ▼ (3) SSE chunk n=0 ← delta.role="assistant" delta.tool_calls[0].function.name="search_flights" │ │ … (多個 chunk,arguments 逐字流回) ▼ (4) SSE chunk 最後一包 ← finish_reason="tool_calls" │ ▼ (5) 伺服器執行你的函式 ↙ 把結果寫成 role:"tool" 再 append │ ▼ (6) 再呼叫 /chat/completions 接續對話(可繼續串流) ``` ### 1️⃣ 第 1 次請求(含工具定義,stream:true) ```json POST /v1/chat/completions { "model": "gpt-4o", "stream": true, "tools": [ { "type": "function", "function": { "name": "search_flights", "description": "查機票", "parameters": { "type": "object", "properties": { "from": { "type": "string" }, "to": { "type": "string" }, "date": { "type": "string" } }, "required": ["from","to","date"] } } } ], "messages": [ { "role": "system", "content": "你是一位旅遊助理" }, { "role": "user", "content": "幫我找 5/1 台北飛大阪的機票" } ] } ``` ### 2️⃣ 串流回傳(逐塊示意) ```json data: { // 第一塊:宣告要呼叫函式 "choices":[{ "delta":{ "role":"assistant", "tool_calls":[{ "id":"call_001", "type":"function", "function":{"name":"search_flights","arguments":"{"} }] } }]} --- data: { "choices":[{ "delta":{"tool_calls":[{ "id":"call_001", "function":{"arguments":"\"from\":\"TPE\",\""}}] } }] } ... data: { "choices":[{ "delta":{"tool_calls":[{ "id":"call_001", "function":{"arguments":"\"to\":\"KIX\",\"date\":\"2025-05-01\"}"} }] } }] } --- data: { "choices":[{ "finish_reason":"tool_calls" }] } ← 參數結束 你此時把 arguments 串成完整 JSON: {"from":"TPE","to":"KIX","date":"2025-05-01"} ``` ### 3️⃣ 後端執行函式 ```json const result = await searchFlights({from:"TPE",to:"KIX",date:"2025-05-01"}); /* 假設回傳 ↓ */ { "flights":[ {"airline":"JX","price":4200,"dep":"07:40","arr":"11:25"}, {"airline":"CI","price":4500,"dep":"09:00","arr":"12:45"} ] } ``` ### 4️⃣ 第 2 次請求(把結果加回對話) ```json POST /v1/chat/completions { "model": "gpt-4o", "stream": true, "messages": [ { "role": "system", "content": "你是一位旅遊助理" }, { "role": "user", "content": "幫我找 5/1 台北飛大阪的機票" }, { // GPT 剛才的 tool_call "role": "assistant", "tool_calls": [{ "id": "call_001", "type": "function", "function": { "name": "search_flights", "arguments": "{\"from\":\"TPE\",\"to\":\"KIX\",\"date\":\"2025-05-01\"}" } }], "content": null }, { // 你的程式塞進來的工具結果 "role": "tool", "tool_call_id": "call_001", "content": "{\"flights\":[{\"airline\":\"JX\",...}]}" } ] } ``` 重要欄位 tool_call_id 必須對應前面 assistant 提供的 id。 content 放函式執行結果(JSON 或純文字皆可)。 所有先前訊息都要保留,讓模型維持上下文。​ 模型收到這組新對話,就會把工具結果讀進記憶並生成最終回覆(仍可串流): ``` 「我找到了兩班直飛:JX 上午 7:40,票價 NT$4 200;CI 上午 9:00,票價 NT$4 500。需要我幫你預訂嗎?」 ``` ## 同一次stream中「先講話、後附帶 function call」 在串流過程中,模型可以先以 delta.content 實時吐出文字,等話說完後,再改吐 delta.function_call。這在社群討論中已被證實可行,常見輸出形態大概長這樣: ``` // 第一段 { "delta": { "role": "assistant" } } { "delta": { "content": "好的,我來查詢……" } } // 話說完後 { "delta": { "content": null, "function_call": { "name": "getWeather" } } } // 參數會繼續分段流出 { "delta": { "function_call": { "arguments": "{\"city\":\"Tokyo\"}" } } } // 最後 { "finish_reason": "function_call" } ``` 需要自己偵測 delta.function_call,並在 finish_reason=="function_call" 時觸發後端函式。 示範流程可參考論壇範例碼: https://community.openai.com/t/functions-calling-with-streaming/305742 注意:OpenAI 規格定義「最終合併後的訊息」若含 function_call,content 應為 null;所以很多函式庫在串流結束時只保留 function_call 而把文字丟掉。你得在 串流時 就把文字收集起來顯示,別等到組裝完成才讀取。 ## function定義 add 還有其他細節 ```swift // 保存任务的方法 private func saveTask() { // 创建新任务 let newTask = TodoTask( title: title, note: content, color: selectedColor, focusTime: 0, // 默认专注时间为0 category: category, isAllDay: isAllDay, isCompleted: false, repeatType: repeat_option, startDate: startDate, endDate: endDate, createdAt: Date() // 使用当前时间作为创建时间 ) // 将新任务添加到全域任务列表 allTasks.tasks.append(newTask) // 新增後自動關閉新增視窗 isPresented = false } ``` todoview ```swift // 儲存任務 private func saveTask() { // 已經在保存中,避免重複觸發 if viewModel.isLoading { return } // 驗證表單 if !validateForm() { return } // 確保離線操作時 UI 不會卡住 viewModel.isLoading = true Task { do { // 將重複選項資料傳遞給 viewModel viewModel.newTaskRepeatType = repeatOption // 獲取當前使用者 ID let userId = Auth.auth().currentUser?.uid ?? "default" // 創建任務,確保包含用戶 ID let task = TodoTask( title: viewModel.newTaskTitle, note: viewModel.newTaskNote, color: viewModel.newTaskColor, focusTime: viewModel.newTaskFocusTime, category: viewModel.newTaskCategory, isAllDay: viewModel.newTaskIsAllDay, isCompleted: false, repeatType: viewModel.newTaskRepeatType, startDate: viewModel.newTaskStartDate, endDate: viewModel.newTaskEndDate, userId: userId ) // 使用 addTask 而非 saveNewTask 以確保處理好 userId await viewModel.addTask(task) // 任務保存成功後關閉視圖 dismissWithAnimation() } catch { // 顯示錯誤訊息 viewModel.errorMessage = error.localizedDescription viewModel.isLoading = false print("Error saving task: \(error.localizedDescription)") } } } ``` todoview model ```swift func addTask(_ task: TodoTask) async { isLoading = true errorMessage = nil // 確保已經登入 guard let currentUserId = Auth.auth().currentUser?.uid else { errorMessage = "請先登入再新增任務" isLoading = false return } do { // 確保任務有使用者 ID var updatedTask = task updatedTask.userId = currentUserId try await firebaseService.saveTodoTask(updatedTask) // 重新加載任務列表以獲取最新數據 try await loadTasks() // 發送資料變更通知 postDataChangeNotification() // 清空錯誤訊息 errorMessage = nil } catch { // 設置錯誤訊息 errorMessage = "儲存任務失敗: \(error.localizedDescription)" print("Error adding task: \(error)") } isLoading = false } ``` firebase service ```swift func saveTodoTask(_ task: TodoTask) async throws { currentSyncStatus = .syncing // 使用 Task 添加超時機制 return try await withTimeout(seconds: 10) { do { let batch = self.db.batch() let userId = self.getCurrentUserId() // 創建一個包含當前使用者ID的任務副本 var updatedTask = task updatedTask.userId = userId // 將任務存儲在使用者ID下 let taskRef = self.db.collection(Collection.tasks.rawValue) .document(userId) .collection("userTasks") .document(task.id) // 儲存任務資料 batch.setData(updatedTask.toFirestore, forDocument: taskRef) try await batch.commit() self.lastSync = Date() self.currentSyncStatus = .synced } catch { self.currentSyncStatus = .error(.networkError) print("Firebase error: \(error.localizedDescription)") throw error } } } ``` # 其他 - [x] OpenAI thread 物件:建議至少保留與此 tool call 相關的對話,否則模型可能遺失上下文。若歷史太長,可裁剪、摘要或使用 OpenAI thread 物件(Assistants v2)來自動管理。 **需要thread物件的情況:** => **要跑 Code Interpreter:** 讓 GPT 幫你算數、畫圖、產生檔案。 **要做 Retrieval / RAG:** 把 PDF、知識庫向量化後,讓 Assistant 自動引用。 **多人共用同一案件:** 多個使用者同時追加訊息,伺服器端必須鎖定同一 conversation id。 **想省掉手動裁切上下文、管理檔案:** Thread 幫你保存並「只抽必要段落」。 **價錢:** 貴 ![image](https://hackmd.io/_uploads/rk-rzMWexl.png =400x) **(結論是目前只有聊天call function功能所以不用)** - [x] Batch API:離峰時段批量丟入,官方直接再折 50%(計畫內含)。 | 需求 | Batch API 優勢 | 同步 API 劣勢 | |------|----------------|---------------| | **大量離線內容生成**(摘要文件、翻譯全文、資料標註) | 一檔丟上去就好,OpenAI 幫你排程;**單價 5 折** | 你得自己做併發、流控、重試;單價原價 | | **不在乎秒級延遲**(可等數分鐘~數小時) | 背景作業,不佔用前端連線 | 同步模式要維持連線,易逾時 | | **想省錢** | 同模型直接省 50 % | — | 「使用者按下送出要立即看到回答」或「需要串流漸進顯示」,Batch 就不適合。 - [x] Cache hit:對重複提示用 cache=true;輸入、輸出各再省 75%。 **解釋:** 當使用者或程式發出請求時,所要的資料已經存在於「快取層」(記憶體、瀏覽器、CDN 邊緣節點、資料庫查詢快取…)裡,系統便直接從這一層取出並回應,不必再去「上游」或「原始來源」(磁碟、主資料庫、遠端伺服器)重抓或重算。這種情況就稱作 命中快取;反之,若快取裡沒有,便是 Cache miss(快取未命中)。 **什麼時候「快取」才值得你加?** 你打算內建教材或題庫檢索 → 同一題目、同一篇文章摘要重複度高。 temperature 設 0、保證 determinism → 同 prompt 才能穩命中。 批次離線作業 → 晚上排程跑,Hit 就回舊答案,Miss 再算。 **如果以上都不是重點,就專心優化 prompt 長度和模型選擇,Cache Hit 可以先放一邊。** - [x] Prompt-to-Response 快取 把「同一段 Prompt → GPT 給出的完整回應」對映成一顆 Key–Value的hash table,先查快取,命中就直接回傳;沒命中才真正呼叫 OpenAI。能把「重複輸入」直接省下來,尤其在 文件翻譯、摘要、FAQ 等大量批次且高重複度的工作最有效。 **自建 Prompt-to-Response 快取 —— 只在「固定問題」有價值** 聊天場景裡,使用者輸入千奇百怪,幾乎不會重複;就算你把 prompt 標準化,命中率仍低於 5%。 真正適合快取 的是:FAQ、固定範本翻譯、文件摘要……這些「同 prompt 重複率高、temperature = 0」的批次任務。 - [ ] 分層路由:先用 nano 做「草稿」,再把需要高精度的少數請求升級到 GPT-4.1。 - [x] openAI自己的向量資料庫(目前先不考慮) - [x] gpt對話紀錄 ### 前端 Streaming - [ ] 使用 response_format: "json_object" 或者普通 text streaming;一旦偵測到 finish_reason=="function_call" 先停止渲染。 執行函式 → 把結果塞回 messages → 重新 call GPT(或走 「工具結果」後續補串流 的模式)。 ### UX 提示 - [ ] 在 UI 上顯示「GPT 正在更新你的行事曆⋯⋯」spinner,等 EventKit 執行完才解除。完成後,由 GPT 再說一句「已經幫你排好 ×××」;這句就來自 function 回傳後 GPT 的下一輪回答,流程一致。 - [ ] token 控制:回傳 JSON 前,用 JSONSerialization 壓成 string,再送給 GPT,避免不必要空白與縮排。(可能要總結的話會比較好) - [x] temperature & top_p ### 容易出現的安全問題排名 - [ ] OWASP Top 10 ### api key 不能寫在app裡面(安全問題) - [x] 邊緣運算 Cloudflare Worker Supabase Edge Function 或是ai ![image](https://hackmd.io/_uploads/SJ0cMu81xx.png) ### 登入問題 - [ ] apple goole 登入後的資料庫要怎麼知道 ### firebase與sapabase比較 - [x] 結果應該還是用firebase ![image](https://hackmd.io/_uploads/r1ZJGO8Jge.png) ![image](https://hackmd.io/_uploads/r13WM_Iyeg.png) ### 免費ai - [x] grog是免費的(但好像有使用量的問題) ### 日曆排序 - [ ] 日歷的陣列用排序的 然後再用lowerbound去找 找大於startdate 然後如果有repaet就不判斷有沒有小於enddate ### cloudflare串流時間 - [x] 看一下gpt的串流是否會降低cloudflare的時間 以及cloudflare有最多10秒的限制(免費方案)30秒(付費方案) ## 程式原理筆記 chatroom:在聊天室泡泡給使用者看的 allMessage:給gpt看的 每次使用者發訊息會從chatroom中載入 過程中append的function結果 不會被一直保存也不會被加到chatroom中 ## 待更新改動 **問題** - [x] 像是取的savetask資訊時聊天室不會顯示正在安排任務 - [x] 對話規則改為 第一次對話使用 "none" 第二次對話使用 "required" 之後根據上一次的 tool_choice 和回覆類型來決定: - 如果上一次是 "required",這次就用 "auto" - 如果上一次是 "auto",則根據上一次回覆的類型決定: - 文字回覆用 "required" - 函數回覆用 "auto" 一直重複到gpt使用endfunction - [x] 聊天室列表要是逆排序 從最後一筆聊天記錄開始 - [x] gpt的save task聊天要可以選顏色 - [ ] 修正gpt問完問題會自己安排task的毛病 - [ ] 加上刪除功能 - [ ] 加上修改功能 - [ ] 測試要不要[wating user] - [ ] 收到回應,狀態碼:429 (太頻繁回應) - [ ] 刪除不一定會真的刪除 - [ ] 刪除匡的顯示資訊有問題 - [x] 新增語調設定 - [x] 讓gpt可以讀取到使用者習慣(把習慣時常調長) - [x] 修正 gpt 不同比對話不會多換一行 今日改動: 新增重新傳送的機制解決錯誤代碼429