戰(zhàn))
1. 為什么 Bedrock Converse API 需要一層 OpenAI 適配器如果你手上有 CLI 編碼工具、Agent 框架或者自研的對(duì)話前端它們大概率只認(rèn) OpenAI 的/v1/chat/completions格式。而 Bedrock 的 Converse API 是另一套結(jié)構(gòu)消息內(nèi)容用 content block 數(shù)組表達(dá)工具調(diào)用走toolUse/toolResult角色還必須嚴(yán)格交替。兩套協(xié)議對(duì)不上工具調(diào)用鏈路就會(huì)斷。我試過(guò)直接用 LiteLLM 做橋接結(jié)果在 Tool Calling 上踩了坑。qwencode 發(fā)出的消息序列里assistant 帶tool_calls后面跟兩條連續(xù)的tool角色消息。LiteLLM 把它們都轉(zhuǎn)成userBedrock 直接拋ValidationException: Messages must alternate between user and assistant roles。更麻煩的是某些版本把tool角色降級(jí)成普通文本模型根本不知道這是工具執(zhí)行結(jié)果循環(huán)就卡死了。所以這篇要解決的核心問(wèn)題是寫(xiě)一個(gè)輕量適配器把 Bedrock Converse API 轉(zhuǎn)成 OpenAI 兼容格式并且完整支持 Tool Calling。適合兩類(lèi)人一是想把 Kimi、DeepSeek、Qwen 這些 Bedrock 上的模型接進(jìn)現(xiàn)有 OpenAI 生態(tài)工具的開(kāi)發(fā)者二是需要統(tǒng)一本地或服務(wù)端調(diào)用入口、不想被 LiteLLM 那套 20 層堆棧拖累的人。適配器要處理三件事消息格式轉(zhuǎn)換、連續(xù)同角色合并、工具調(diào)用雙向映射。下面從接入入口開(kāi)始一步步給出可復(fù)制的配置和驗(yàn)證命令。2. TaoToken 作為統(tǒng)一入口的前置準(zhǔn)備在寫(xiě)適配器之前先把調(diào)用入口統(tǒng)一掉。TaoToken 提供 OpenAI 兼容的 API 端點(diǎn)Base URL 是https://taotoken.net/api你可以把它當(dāng)成一個(gè)標(biāo)準(zhǔn)的 OpenAI 服務(wù)來(lái)用。這樣適配器只需要面向一套協(xié)議不用為每個(gè)上游單獨(dú)寫(xiě)分支。先拿 API Key。打開(kāi)https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi_keysutm_campaignrewrite創(chuàng)建一個(gè) Key格式類(lèi)似sk-開(kāi)頭的一串字符。這個(gè) Key 后面會(huì)同時(shí)用在適配器的鑒權(quán)校驗(yàn)和上游請(qǐng)求里。模型 ID 需要確認(rèn)清楚。Bedrock 上的模型 ID 通常帶廠商前綴和版本后綴比如moonshotai.kimi-k2.5、deepseek.v3.2、qwen.qwen3-coder-next、amazon.nova-pro-v1:0、mistral.mistral-large-3-675b-instruct。這些模型在 Tool Calling 支持度上不完全一樣實(shí)測(cè)下來(lái) Kimi、DeepSeek、Qwen3 Coder、Nova Pro、Mistral Large 都能完整走通工具調(diào)用zai.glm-4.7比較特殊能發(fā)起工具調(diào)用但接收toolResult時(shí)支持不完整選型時(shí)要留意。如果你更想先驗(yàn)證模型對(duì)話效果可以直接在https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_contentmodel_chatutm_campaignrewrite里試。長(zhǎng)期跑編碼 Agent 的話https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding_planutm_campaignrewrite更適合因?yàn)楣ぞ哒{(diào)用是高頻操作配額和穩(wěn)定性比單次對(duì)話重要得多。接入文檔在https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite里面有完整的端點(diǎn)說(shuō)明和參數(shù)列表??刂婆_(tái)在https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite可以看調(diào)用量和錯(cuò)誤分布。前置準(zhǔn)備就三樣Base URL、API Key、Model ID。這三件套在后面的配置片段里會(huì)反復(fù)出現(xiàn)先記牢。3. 適配器配置片段與消息轉(zhuǎn)換實(shí)現(xiàn)這一節(jié)給出可直接復(fù)制的配置和核心轉(zhuǎn)換代碼。適配器用 FastAPI 寫(xiě)依賴boto3和uvicorn。先裝依賴pip install fastapi uvicorn boto3配置文件用 JSON 表達(dá)路徑放在項(xiàng)目根目錄的config.json{ server: { host: 0.0.0.0, port: 8765 }, upstream: { base_url: https://taotoken.net/api, api_key: sk-你的Key, default_model: moonshotai.kimi-k2.5 }, bedrock: { region: us-east-1, profile: global }, adapter: { api_key: sk-local-adapter-key, merge_same_role: true, max_tokens: 2048, temperature: 0.7 } }如果你用 TOML 風(fēng)格管理等價(jià)寫(xiě)法是[server] host 0.0.0.0 port 8765 [upstream] base_url https://taotoken.net/api api_key sk-你的Key default_model moonshotai.kimi-k2.5 [bedrock] region us-east-1 profile global [adapter] api_key sk-local-adapter-key merge_same_role true max_tokens 2048 temperature 0.7核心轉(zhuǎn)換函數(shù)分三塊。第一塊是 OpenAI 到 Bedrock 的消息轉(zhuǎn)換import json def convert_to_bedrock(messages): OpenAI messages - Bedrock messages system system bedrock_messages [] for msg in messages: role msg[role] content msg.get(content, ) if role system: system content if isinstance(content, str) else continue if role tool: tool_call_id msg.get(tool_call_id, ) if isinstance(content, list): content \n.join( item.get(text, str(item)) if isinstance(item, dict) else str(item) for item in content ) bedrock_messages.append({ role: user, content: [{toolResult: { toolUseId: tool_call_id, content: [{text: str(content)}] }}] }) continue if role assistant: has_content content and ( (isinstance(content, str) and content.strip()) or (isinstance(content, list) and len(content) 0) ) if has_content: if isinstance(content, list): content \n.join( item.get(text, str(item)) if isinstance(item, dict) else str(item) for item in content ) bedrock_messages.append({ role: assistant, content: [{text: str(content)}] }) for tc in msg.get(tool_calls, []): bedrock_messages.append({ role: assistant, content: [{toolUse: { toolUseId: tc[id], name: tc[function][name], input: json.loads(tc[function][arguments]) if tc[function][arguments] else {} }}] }) continue # user if isinstance(content, list): content \n.join( item.get(text, str(item)) if isinstance(item, dict) else str(item) for item in content ) if content: bedrock_messages.append({ role: user, content: [{text: str(content)}] }) return system, bedrock_messages第二塊是連續(xù)同角色合并這是 Bedrock 的硬性要求def merge_messages(messages): 合并連續(xù)同角色消息Bedrock 要求 user/assistant 嚴(yán)格交替 if not messages: return messages result [] for msg in messages: if result and result[-1][role] msg[role]: result[-1][content].extend(msg[content]) else: result.append({role: msg[role], content: list(msg[content])}) return result第三塊是 Bedrock 響應(yīng)轉(zhuǎn)回 OpenAI 格式import time import uuid def convert_from_bedrock(response, model): content tool_calls [] if output in response and message in response[output]: for block in response[output][message].get(content, []): if text in block: content block[text] elif toolUse in block: tool_use block[toolUse] tool_calls.append({ id: tool_use[toolUseId], type: function, function: { name: tool_use[name], arguments: json.dumps(tool_use.get(input, {})) } }) finish_reason tool_calls if response.get(stopReason) tool_use else stop choice { index: 0, message: {role: assistant, content: content if content else None}, finish_reason: finish_reason } if tool_calls: choice[message][tool_calls] tool_calls return { id: fchatcmpl-{uuid.uuid4().hex[:8]}, object: chat.completion, created: int(time.time()), model: model, choices: [choice], usage: { prompt_tokens: response.get(usage, {}).get(inputTokens, 0), completion_tokens: response.get(usage, {}).get(outputTokens, 0), total_tokens: response.get(usage, {}).get(totalTokens, 0) } }Tools 配置轉(zhuǎn)換單獨(dú)拎出來(lái)OpenAI 的tools數(shù)組要映射成 Bedrock 的toolConfigdef convert_tools(tools): if not tools: return None return { tools: [{ toolSpec: { name: tool[function][name], description: tool[function].get(description, ), inputSchema: {json: tool[function].get(parameters, {})} } } for tool in tools if tool.get(type) function] }注意merge_messages必須在convert_to_bedrock之后調(diào)用。順序反了連續(xù) tool 消息會(huì)先被合并成一條toolUseId就丟了。4. 用 curl 驗(yàn)證 OpenAI 兼容端點(diǎn)與工具調(diào)用鏈路適配器跑起來(lái)后先驗(yàn)證基礎(chǔ)對(duì)話再驗(yàn)證工具調(diào)用。啟動(dòng)服務(wù)python adapter.py服務(wù)監(jiān)聽(tīng)0.0.0.0:8765。先測(cè)普通對(duì)話curl -s http://127.0.0.1:8765/v1/chat/completions \ -H Authorization: Bearer sk-local-adapter-key \ -H Content-Type: application/json \ -d { model: moonshotai.kimi-k2.5, messages: [ {role: user, content: 用一句話說(shuō)明什么是適配器} ], max_tokens: 128 }預(yù)期返回結(jié)構(gòu)里有choices[0].message.contentfinish_reason是stop。如果返回 401檢查Authorization頭是否和config.json里的adapter.api_key一致。再測(cè)工具調(diào)用。構(gòu)造一個(gè)帶tools的請(qǐng)求curl -s http://127.0.0.1:8765/v1/chat/completions \ -H Authorization: Bearer sk-local-adapter-key \ -H Content-Type: application/json \ -d { model: deepseek.v3.2, messages: [ {role: user, content: 幫我創(chuàng)建文件 notes.txt內(nèi)容寫(xiě) hello} ], tools: [{ type: function, function: { name: write_file, description: 寫(xiě)入文件, parameters: { type: object, properties: { path: {type: string}, content: {type: string} }, required: [path, content] } } }], max_tokens: 256 }成功時(shí)finish_reason應(yīng)該是tool_callsmessage.tool_calls[0].function.name是write_filearguments里是 JSON 字符串。拿到這個(gè)結(jié)果后把工具執(zhí)行結(jié)果回傳驗(yàn)證完整循環(huán)curl -s http://127.0.0.1:8765/v1/chat/completions \ -H Authorization: Bearer sk-local-adapter-key \ -H Content-Type: application/json \ -d { model: deepseek.v3.2, messages: [ {role: user, content: 幫我創(chuàng)建文件 notes.txt內(nèi)容寫(xiě) hello}, {role: assistant, content: null, tool_calls: [{ id: call_abc123, type: function, function: {name: write_file, arguments: {\path\:\notes.txt\,\content\:\hello\}} }]}, {role: tool, tool_call_id: call_abc123, content: 文件寫(xiě)入成功} ], tools: [{ type: function, function: { name: write_file, description: 寫(xiě)入文件, parameters: { type: object, properties: { path: {type: string}, content: {type: string} }, required: [path, content] } } }], max_tokens: 256 }這一步是驗(yàn)證tool角色轉(zhuǎn)toolResult的關(guān)鍵。如果適配器正確模型會(huì)基于工具結(jié)果繼續(xù)生成自然語(yǔ)言回復(fù)finish_reason回到stop。如果這里報(bào)ValidationException說(shuō)明連續(xù)同角色合并沒(méi)生效或者toolResult結(jié)構(gòu)拼錯(cuò)了。流式驗(yàn)證用stream: true觀察 SSE 事件里delta.tool_calls是否分片到達(dá)。工具調(diào)用的參數(shù)是逐塊拼接的客戶端要按index累積arguments字符串。5. 常見(jiàn)報(bào)錯(cuò)排查對(duì)照表這一節(jié)按真實(shí)報(bào)錯(cuò)來(lái)對(duì)。適配器跑不通基本集中在下面幾類(lèi)。401 Missing API key / Invalid API key適配器自身的鑒權(quán)失敗。檢查請(qǐng)求頭Authorization: Bearer sk-local-adapter-key是否和config.json里adapter.api_key完全一致。注意 Bearer 后面有一個(gè)空格Key 不要帶引號(hào)。ValidationException: Messages must alternate between user and assistant roles這是最典型的。原因就是連續(xù)同角色消息沒(méi)合并。OpenAI 的tool角色轉(zhuǎn)成 Bedrock 的user后如果前面已經(jīng)有一條user就會(huì)連續(xù)。解決方法是確保merge_messages在轉(zhuǎn)換之后被調(diào)用并且合并時(shí)用extend而不是覆蓋content。ValidationException: The toolResult toolUseId does not match any toolUsetool_call_id對(duì)不上。檢查 assistant 消息里tool_calls[].id和 tool 消息里tool_call_id是否一致。有些客戶端會(huì)重新生成 ID適配器要原樣透?jìng)鞑荒芨膶?xiě)。local proxy failed / connection refused適配器沒(méi)啟動(dòng)或者端口被占。用lsof -i :8765查一下。如果是從容器里訪問(wèn)宿主機(jī)127.0.0.1要換成宿主機(jī)的實(shí)際地址。reading choices: unexpected end of JSON input上游返回了非 JSON 內(nèi)容通常是上游報(bào)錯(cuò)被直接透?jìng)?。打開(kāi)適配器日志看Calling Bedrock with modelId后面的實(shí)際請(qǐng)求。常見(jiàn)原因是模型 ID 寫(xiě)錯(cuò)比如把moonshotai.kimi-k2.5寫(xiě)成kimi-k2.5Bedrock 找不到模型。OAuth / auth.json 相關(guān)報(bào)錯(cuò)如果你用 Codex 或 Claude Code 這類(lèi)工具它們的auth.json或 OAuth 流程可能覆蓋了 Base URL。以 Codex 為例~/.codex/auth.json里要確認(rèn)OPENAI_BASE_URL指向適配器地址OPENAI_API_KEY填適配器的 Key。三件套必須同時(shí)對(duì)齊Base URL、Key、Model ID。少一個(gè)都會(huì)在工具調(diào)用階段暴露問(wèn)題。模型返回空輸入導(dǎo)致輸出中斷這是 Bedrock 在長(zhǎng)鏈路里偶發(fā)的問(wèn)題模型可能返回空 content block。適配器里可以在convert_from_bedrock加一層判斷如果content和tool_calls都為空補(bǔ)一個(gè)默認(rèn)文本避免客戶端解析崩潰。zai.glm-4.7 工具結(jié)果不生效這個(gè)模型只能發(fā)起工具調(diào)用接收toolResult時(shí)支持不完整。如果業(yè)務(wù)強(qiáng)依賴工具循環(huán)換qwen.qwen3-coder-next或deepseek.v3.2。排查時(shí)優(yōu)先看適配器日志里的三段Converted: N messages - M bedrock messages、Bedrock messages:、Response:。轉(zhuǎn)換前后的消息數(shù)量對(duì)不上問(wèn)題一定在合并邏輯Bedrock 請(qǐng)求體正常但響應(yīng)異常問(wèn)題在上游模型或參數(shù)。6. 把適配器接進(jìn)你的工具鏈適配器跑通后接入現(xiàn)有工具只需要改 Base URL。以 Cline 或 CC Switch 這類(lèi)支持自定義端點(diǎn)的工具為例配置里填三件套Base URL 用http://127.0.0.1:8765/v1API Key 用適配器的 KeyModel ID 用 Bedrock 的完整模型 ID。Cline 的 MCP 配置里如果涉及工具調(diào)用同樣走這套端點(diǎn)不需要額外改協(xié)議。如果你不想自己維護(hù)適配器進(jìn)程也可以直接用 TaoToken 的 OpenAI 兼容端點(diǎn)把 Base URL 設(shè)成https://taotoken.net/apiKey 用https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi_keysutm_campaignrewrite里創(chuàng)建的模型 ID 按文檔填。這樣省掉本地適配器這一層工具調(diào)用鏈路直接由上游處理。接入細(xì)節(jié)看https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite。長(zhǎng)期跑編碼 Agent 的話建議把適配器和上游配額分開(kāi)管理。適配器負(fù)責(zé)協(xié)議轉(zhuǎn)換上游負(fù)責(zé)模型調(diào)度。Coding Plan 在https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding_planutm_campaignrewrite適合工具調(diào)用頻繁的場(chǎng)景。想先驗(yàn)證模型對(duì)話效果去https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_contentmodel_chatutm_campaignrewrite試。最后留一個(gè)實(shí)操建議適配器的日志級(jí)別先開(kāi)到 INFO把轉(zhuǎn)換前后的消息都打出來(lái)。工具調(diào)用出問(wèn)題時(shí)對(duì)比 OpenAI 請(qǐng)求里的tool_calls和 Bedrock 請(qǐng)求里的toolUse字段名和嵌套層級(jí)一眼就能看出差異。等鏈路穩(wěn)定了再降到 WARNING避免日志刷屏。