戰(zhàn):從架構(gòu)設(shè)計(jì)到模型調(diào)度與踩坑記錄)
“又一個(gè)新項(xiàng)目完結(jié)”——這句話說(shuō)出口的時(shí)候我終于能把“全棧 AI 修圖 Agent”從待辦列表里劃掉了。這個(gè)項(xiàng)目從立項(xiàng)到交付前后差不多一個(gè)多月期間推翻過一版架構(gòu)也踩了不少模型和前后端的坑。如果你最近也在折騰 AI 全棧項(xiàng)目或者想看看 Agent 工程化到底怎么落地這篇博文應(yīng)該能給你省下不少時(shí)間。一句話介紹這個(gè)項(xiàng)目它是一套基于自然語(yǔ)言的修圖系統(tǒng)。用戶上傳一張圖輸入一句“幫我把背景換掉順便調(diào)亮一點(diǎn)”系統(tǒng)里的 AI Agent 會(huì)自動(dòng)理解意圖、拆分任務(wù)、調(diào)度模型、執(zhí)行修圖最后返回成圖。整個(gè)系統(tǒng)覆蓋 Web 端和移動(dòng)端前端用了 Vue3 和 Uniapp后端用的 Golang中間夾了一層 Agent 任務(wù)編排與模型網(wǎng)關(guān)。本文會(huì)把項(xiàng)目的設(shè)計(jì)思路、技術(shù)選型、核心實(shí)現(xiàn)和踩坑記錄全部拆開來(lái)講適合想獨(dú)立做出一個(gè) AI 全棧作品的開發(fā)者參考。1. 項(xiàng)目整體設(shè)計(jì)與需求拆解1.1 這個(gè)項(xiàng)目到底想解決什么問題傳統(tǒng)修圖軟件的學(xué)習(xí)成本很高。想給一個(gè)非專業(yè)人士解釋清楚“圖層、蒙版、選區(qū)、曲線”實(shí)在太難了。哪怕是我自己遇到批量老照片修復(fù)或者一鍵換背景也不可能每張圖都去 Photoshop 里手動(dòng)操作。所以這個(gè)項(xiàng)目的核心定位非常明確用自然語(yǔ)言把修圖意圖說(shuō)清楚剩下的交給 Agent 去拆解和執(zhí)行。舉一個(gè)最能說(shuō)明問題的例子。用戶輸入“把照片的背景換成秋天的銀杏葉”傳統(tǒng)的做法是什么樣的需要把人像摳出來(lái)、生成新的背景、再做人像與背景的融合、最后統(tǒng)一色調(diào)。這一套流程在 Photoshop 里至少涉及四五個(gè)環(huán)節(jié)。而在我們的系統(tǒng)里Agent 會(huì)把這句話拆成“語(yǔ)義分割人像、生成銀杏背景、圖像融合、色彩匹配”四個(gè)子任務(wù)再依次調(diào)用對(duì)應(yīng)的模型工具最終合成一張成品圖。用戶不需要理解任何技術(shù)細(xì)節(jié)只需要表達(dá)“要什么效果”。這就是 Agent 和普通接口的本質(zhì)區(qū)別。普通接口是“請(qǐng)求-處理-返回”的單次服務(wù)。Agent 則是一個(gè)帶循環(huán)判斷的系統(tǒng)接收用戶需求、調(diào)度工具、檢查結(jié)果、決定是否繼續(xù)調(diào)整直到任務(wù)完成或者達(dá)到輪次上限。1.2 核心功能與能力邊界在實(shí)際開發(fā)之前我先把功能邊界畫了一個(gè)表確定了第一版必須支持和可以延后的部分。這里直接把我們項(xiàng)目最初的功能清單分享出來(lái)功能模塊具體能力優(yōu)先級(jí)圖片上傳與存儲(chǔ)支持 JPG/PNG/WebP統(tǒng)一壓縮到 2048pxOSS 存儲(chǔ)必須人像美化磨皮、美白、瘦臉等參數(shù)化操作必須背景處理?yè)笀D、背景替換、背景虛化必須老照片修復(fù)去噪、上色、超分必須色彩調(diào)整提亮、加濾鏡、色溫調(diào)整必須畫面擴(kuò)展以圖生圖方式擴(kuò)展畫面外區(qū)域延后批量處理一次提交多張圖延后風(fēng)格模板一鍵套用某種預(yù)設(shè)風(fēng)格延后第一版不做批量處理是我反復(fù)權(quán)衡以后的決定。原因很簡(jiǎn)單Agent 帶多輪工具調(diào)用的場(chǎng)景里單張圖的執(zhí)行鏈路已經(jīng)很復(fù)雜如果再加上批量任務(wù)的并發(fā)編排問題排查難度會(huì)成倍增加。先跑通單張閉環(huán)再往橫向擴(kuò)展是這一類項(xiàng)目的穩(wěn)妥節(jié)奏。1.3 Agent 修圖與傳統(tǒng)修圖的架構(gòu)差異很多剛接觸 Agent 開發(fā)的人會(huì)陷入一個(gè)誤區(qū)以為 Agent 就是在普通后端接口前面加一層大模型接口用戶說(shuō)什么直接轉(zhuǎn)給大模型大模型返回什么就展示什么。但真正做過以后你會(huì)發(fā)現(xiàn)修圖場(chǎng)景完全不是這么回事。一張圖的修圖結(jié)果沒辦法在文本對(duì)話里直接返回大模型能做的只是“決定該做什么”具體的圖像處理必須由后端去調(diào)模型完成。所以我們的架構(gòu)里Agent 扮演的不是“回答者”而是“調(diào)度中樞”。它接收用戶需求輸出結(jié)構(gòu)化的工具調(diào)用指令后端拿到指令去執(zhí)行真實(shí)的圖像處理執(zhí)行完再把結(jié)果摘要回傳給 Agent讓它判斷任務(wù)是否結(jié)束或者下一步該做什么。這個(gè)設(shè)計(jì)直接決定了后續(xù)數(shù)據(jù)庫(kù)要怎么設(shè)計(jì)、接口要怎么通信、前端狀態(tài)要怎么管理??梢哉f(shuō)所有在開發(fā)過程中讓人頭疼的問題最終都指向同一個(gè)根因這是一個(gè)異步、多步驟、帶狀態(tài)的 AI 工作流而不是一個(gè)同步的查詢接口。2. 技術(shù)選型與整體架構(gòu)設(shè)計(jì)2.1 前端為什么選 Vue3 和 Uniapp前端選擇 Vue3首先是因?yàn)閳F(tuán)隊(duì)技術(shù)棧本來(lái)就是 Vue后面接手項(xiàng)目的人不至于看不懂。另外一個(gè)更實(shí)際的原因修圖工作臺(tái)的界面狀態(tài)非常復(fù)雜畫布區(qū)、參數(shù)面板、歷史記錄、指令輸入框之間需要頻繁跨組件通信。Vue3 的組合式 API 在處理這類復(fù)雜交互時(shí)代碼組織形式比 Vue2 的 Options API 清晰得多。移動(dòng)端選擇 Uniapp考慮的是投入產(chǎn)出比。我們的目標(biāo)不只是做 Web 端還希望用戶能在手機(jī)上隨手拍一張圖直接處理。Uniapp 一套代碼能同時(shí)輸出 H5 和原生 App雖然性能上不如真正的原生開發(fā)但對(duì)于圖片展示和表單交互這種場(chǎng)景已經(jīng)完全夠用。更關(guān)鍵的是uniapp 對(duì) Vue 語(yǔ)法的兼容度很高Web 端的一些邏輯代碼甚至可以直接復(fù)制到移動(dòng)端使用省了非常多的重復(fù)工作量。這里要補(bǔ)充一個(gè)經(jīng)驗(yàn)如果你只有一個(gè)前端同學(xué)想做多端Uniapp 是合理選擇但如果是重交互的圖片編輯器建議 Web 端還是單獨(dú)用 Canvas 和 WebGL 做好基礎(chǔ)再考慮遷移。我們第一版把大部分復(fù)雜操作放在 Web 端移動(dòng)端只做上傳、預(yù)覽和結(jié)果展示就是為了避開移動(dòng)端 Canvas 性能的坑。2.2 后端為什么選擇 Golang后端選型我其實(shí)考慮過 Node.js、Python 和 Golang 三個(gè)方向。Node.js 的好處是前端同學(xué)能直接上手但我們的任務(wù)編排層涉及大量并發(fā)調(diào)度我希望有一個(gè)更強(qiáng)的并發(fā)模型。Python 在 AI 生態(tài)里確實(shí)無(wú)敵但部署要帶運(yùn)行環(huán)境和一堆依賴維護(hù)起來(lái)并不輕松。最后敲定 Golang理由有三個(gè)。第一Goroutine 的并發(fā)模型對(duì) AI 任務(wù)調(diào)度非常友好。一個(gè)修圖 Agent 任務(wù)會(huì)拆成多個(gè)子任務(wù)其中一些子任務(wù)之間是可以并行的比如同時(shí)做人像摳圖和色彩分析。用 goroutine 做并行調(diào)度比起線程池和協(xié)程都更直觀。第二Golang 編譯出來(lái)就是單個(gè)二進(jìn)制文件部署到服務(wù)器沒有任何環(huán)境依賴問題。我們的 AI 模型部分雖然跑在獨(dú)立的 GPU 機(jī)器上但服務(wù)端只需要通過 HTTP 調(diào)用模型接口所以 Golang 并不影響 AI 能力的接入。第三Gin 框架提供了非常簡(jiǎn)潔的路由和中間件機(jī)制SSE 流式輸出的實(shí)現(xiàn)也足夠穩(wěn)定。我們整個(gè)后端只用了 Gin、GORM 和 SSE 相關(guān)的幾個(gè)標(biāo)準(zhǔn)庫(kù)沒有引入額外重框架。2.3 AI 模型層本地模型與云端 API 混合路線模型層的設(shè)計(jì)是這個(gè)項(xiàng)目里最考驗(yàn)取舍的部分。最開始我考慮過全部接入開源模型自部署比如 Stable Diffusion 和 GFPGAN。但實(shí)際跑下來(lái)發(fā)現(xiàn)不同子任務(wù)的模型效果差異極大。有些開源小模型在老照片修復(fù)上效果根本不夠看而商業(yè) API 的表現(xiàn)則是開箱即用。最終我采用了混合路線主控 Agent意圖識(shí)別、工具參數(shù)抽取使用商用大模型 API因?yàn)樗闹噶罾斫夂?JSON 結(jié)構(gòu)化輸出能力穩(wěn)定這是整個(gè)鏈條里最不能出錯(cuò)的部分。圖像生成和編輯類任務(wù)使用開源 Stable Diffusion 系列模型部署在自己的 GPU 服務(wù)器上因?yàn)檫@類操作請(qǐng)求量大走本地模型可以控制成本。一些專項(xiàng)能力比如人像深度摳圖、老照片修復(fù)用的是商業(yè) API。這類任務(wù)對(duì)結(jié)果質(zhì)量要求高開源小模型很難達(dá)到同一水平。這套混合架構(gòu)的核心是一個(gè)模型網(wǎng)關(guān)層。后端服務(wù)不關(guān)心具體調(diào)的是本地服務(wù)還是外部 API只面向一個(gè)統(tǒng)一的接口封裝。模型網(wǎng)關(guān)根據(jù)任務(wù)類型和當(dāng)前服務(wù)器負(fù)載把請(qǐng)求路由到對(duì)應(yīng)的模型實(shí)例。這給項(xiàng)目帶來(lái)了兩個(gè)直接好處一是某個(gè)模型掛了不會(huì)影響整個(gè)鏈路二是后續(xù)替換某個(gè)子任務(wù)的模型時(shí)不需要改動(dòng)上層邏輯。2.4 整體架構(gòu)分層整個(gè)系統(tǒng)從邏輯上可以分成五層展示層Vue3 Web 管理端、Uniapp 移動(dòng)端接入層Golang 后端提供 REST 接口和 SSE 流式推送Agent 編排層負(fù)責(zé)意圖理解、任務(wù)拆分、工具調(diào)用、結(jié)果驗(yàn)證工具執(zhí)行層每個(gè)修圖能力都封裝成一個(gè)獨(dú)立工具模型網(wǎng)關(guān)層統(tǒng)一調(diào)度本地模型和商業(yè) API這里最關(guān)鍵的一點(diǎn)是 Agent 編排層和工作流運(yùn)行時(shí)的邊界要清楚。Agent 只輸出“決策結(jié)果”而工作流運(yùn)行時(shí)負(fù)責(zé)執(zhí)行、狀態(tài)管理和錯(cuò)誤處理。如果沒有這個(gè)邊界所有邏輯都塞在 Agent 的對(duì)話循環(huán)里后期幾乎沒法維護(hù)。3. Agent 核心工作流的落地實(shí)現(xiàn)3.1 給 Agent 建立“能力說(shuō)明書”一個(gè)修圖 Agent 首先要讓大模型知道自己有什么工具、能做什么事、不該做什么事。這部分我通過系統(tǒng)提示詞來(lái)實(shí)現(xiàn)。Project 的 System Prompt 大致是如下結(jié)構(gòu)你是一個(gè)修圖任務(wù)調(diào)度管家負(fù)責(zé)將用戶的修圖需求拆解為可執(zhí)行的工具調(diào)用序列。 你擁有以下工具 1. remove_background(image_id, output_format)移除圖片背景 2. replace_background(image_id, background_prompt)替換圖片背景 3. face_retouch(image_id, smooth_level, whitening_level)人像美化 4. restore_photo(image_id)老照片修復(fù) 5. adjust_color(image_id, brightness, contrast, saturation)色彩調(diào)整 ... 要求 1. 用戶需求無(wú)法用現(xiàn)有工具實(shí)現(xiàn)時(shí)直接返回 error 說(shuō)明原因 2. 一次請(qǐng)求可以調(diào)用多個(gè)工具工具之間按執(zhí)行順序排列 3. 結(jié)果滿意后必須返回 done不要重復(fù)執(zhí)行已完成的步驟這套提示詞的核心是“給模型畫清邊界”。在測(cè)試過程中我明顯感覺到提示詞里如果沒有明確說(shuō)明“不要重復(fù)執(zhí)行已完成的步驟”模型就很容易在第一步執(zhí)行完后繼續(xù)重復(fù)相同的工具調(diào)用白白浪費(fèi)時(shí)間和算力。3.2 讓大模型穩(wěn)定輸出工具調(diào)用參數(shù)Agent 和工具層之間的通信必須用結(jié)構(gòu)化的方式不能直接讓模型輸出自然語(yǔ)言否則下游解析會(huì)非常痛苦。我們要求大模型始終返回一個(gè) JSON 對(duì)象結(jié)構(gòu)固定如下{ thought: 用戶需要替換背景并調(diào)亮我先執(zhí)行背景替換, tool_calls: [ { tool_name: replace_background, arguments: { image_id: img_10001, background_prompt: autumn gingko leaves } }, { tool_name: adjust_color, arguments: { image_id: img_10001, brightness: 1.2 } } ], is_finished: false }這里的is_finished是一個(gè)重要字段。模型完成所有工具調(diào)用后要把這個(gè)字段置為 true服務(wù)端看到 true 才會(huì)結(jié)束循環(huán)。為了提升 JSON 解析的成功率我把大模型接口的 temperature 參數(shù)刻意調(diào)低到 0.2 以內(nèi)同時(shí)對(duì)返回內(nèi)容做了一層清洗處理去掉 Markdown 代碼塊標(biāo)記、修正未閉合的花括號(hào)。還有一個(gè)容易踩的坑是圖像處理的中間結(jié)果管理。多個(gè)工具需要串行處理同一張圖片時(shí)每次執(zhí)行的輸入可能是上一個(gè)工具的輸出。因此我們不能讓每個(gè)工具都從原始圖片開始處理而是在運(yùn)行時(shí)維護(hù)一個(gè)“當(dāng)前圖片”狀態(tài)。比如上面對(duì)話的 JSON 里第二次調(diào)用的參數(shù)還是 image_id但實(shí)際在工具執(zhí)行層該 id 對(duì)應(yīng)的圖片地址已經(jīng)被第一次的工具調(diào)用結(jié)果覆蓋了。3.3 工具注冊(cè)與動(dòng)態(tài)調(diào)度機(jī)制為了讓 Agent 的“能力說(shuō)明書”和實(shí)際的工具代碼保持一致我實(shí)現(xiàn)了一套簡(jiǎn)單的工具注冊(cè)機(jī)制。每個(gè)工具都是一個(gè)結(jié)構(gòu)體type Tool interface { Name() string Description() string Parameters() map[string]interface{} Execute(ctx *ToolContext, args map[string]interface{}) (*ToolResult, error) }系統(tǒng)啟動(dòng)時(shí)所有工具會(huì)注冊(cè)到一個(gè)工具表里。注冊(cè)的同時(shí)會(huì)把工具的名字、描述、參數(shù) Schema 自動(dòng)拼接到 System Prompt 中。這樣做的好處是以后新增一個(gè)修圖能力只需要實(shí)現(xiàn) Tool 接口然后在啟動(dòng)入口注冊(cè)一下Agent 下次對(duì)話時(shí)就會(huì)自動(dòng)感知到新工具的存在不需要手動(dòng)修改提示詞。動(dòng)態(tài)調(diào)度的時(shí)候通過工具名從注冊(cè)表找到對(duì)應(yīng)的 Tool 實(shí)例參數(shù)則從 Agent 返回的 JSON 中抽取。有一個(gè)經(jīng)驗(yàn)值得記錄參數(shù)校驗(yàn)一定要做兩層。第一層是 Agent 層的 Schema 校驗(yàn)第二層是工具內(nèi)部的類型斷言。因?yàn)榇竽P团紶枙?huì)返回brightness: 1.2這種字符串類型的數(shù)字如果工具內(nèi)部不轉(zhuǎn)類型程序就會(huì)直接 panic。3.4 多輪工具循環(huán)的狀態(tài)管理一個(gè)修圖需求往往需要好幾輪工具調(diào)用。比如“把背景換掉順便調(diào)亮”這個(gè)需求如果讓 Agent 一次完成其實(shí)只需要一輪。但如果是“把背景換掉、調(diào)亮、再把人的皮膚磨一下”這種需求Agent 可能需要兩到三輪才能把參數(shù)逐步確認(rèn)好。整個(gè)循環(huán)的代碼結(jié)構(gòu)是經(jīng)典的 while state update 模式for i : 0; i maxIterations; i { currentState : buildState(conversation, currentImageURL) llmResponse : callAgent(systemPrompt, currentState) if llmResponse.IsFinished { break } for _, call : range llmResponse.ToolCalls { tool : registry.Get(call.ToolName) result : tool.Execute(ctx, call.Arguments) appendToolResult(conversation, result) } }循環(huán)跳出條件有兩個(gè)一是模型返回is_finished: true二是達(dá)到最大輪次上限。我給默認(rèn)值設(shè)成了 5 輪超過 5 輪直接返回當(dāng)前結(jié)果。這個(gè)上限非常重要沒有它Agent 在某些奇怪輸入下會(huì)一直循環(huán)調(diào)工具最終超時(shí)或燒光 token。3.5 結(jié)果是不是真的符合預(yù)期Agent 修圖還有一個(gè)很容易被忽視的問題怎么判斷結(jié)果“成功了”。之前我以為這一步很順理成成實(shí)際開發(fā)時(shí)才發(fā)現(xiàn)圖像處理工具到底有沒有成功不能只看模型返回的 JSON 里success: true。有些模型返回成功但生成的結(jié)果圖可能是黑圖、糊圖或者與原圖毫無(wú)關(guān)系。所以我在工具執(zhí)行層加了一個(gè)基礎(chǔ)質(zhì)量校驗(yàn)?zāi)K對(duì)所有模型輸出的圖片做三項(xiàng)檢查寬度和高度是否大于預(yù)設(shè)閾值、圖片文件字節(jié)數(shù)是否低于異常下限、清晰度評(píng)分是否超過基礎(chǔ)線。任何一項(xiàng)不通過就會(huì)觸發(fā)一次自動(dòng)重試或者直接讓 Agent 判定當(dāng)前工具執(zhí)行失敗。這個(gè)設(shè)計(jì)確實(shí)救過我們一次。有一版我們?cè)跍y(cè)試背景替換時(shí)某個(gè)場(chǎng)景的模型輸出偶爾會(huì)出現(xiàn)純色填充但接口本身是 200 狀態(tài)。如果沒有質(zhì)量校驗(yàn)用戶看到的就會(huì)是一張全黑圖而且系統(tǒng)還認(rèn)為任務(wù)完成了。4. 全棧實(shí)現(xiàn)與多端聯(lián)調(diào)過程4.1 后端接口設(shè)計(jì)與 SSE 任務(wù)推送后端的核心接口大致分三組。第一組是會(huì)話管理接口負(fù)責(zé)創(chuàng)建會(huì)話和查詢歷史記錄第二組是文件接口負(fù)責(zé)圖片上傳和臨時(shí) URL 生成第三組是任務(wù)接口負(fù)責(zé)提交 Agent 任務(wù)和接收進(jìn)度推送。任務(wù)提交通道我最終選擇了 SSEServer-Sent Events方案。原因很直接Agent 任務(wù)的處理時(shí)間通常需要 10 到 30 秒甚至更久用戶需要實(shí)時(shí)看到“正在理解需求”“正在執(zhí)行背景替換”“正在調(diào)整色調(diào)”這樣的中間狀態(tài)。如果只用輪詢不僅浪費(fèi)請(qǐng)求資源體驗(yàn)也差了很多。SSE 的推送格式設(shè)計(jì)成了事件流。前端通過 EventSource 連接后后端按階段推事件event: stage data: {stage: understanding, message: 正在理解您的修圖需求} event: stage data: {stage: tool_executing, tool: replace_background, message: 正在執(zhí)行背景替換} event: stage data: {stage: image_ready, image_url: https://cdn.xxx.com/result.jpg}前端只需要監(jiān)聽不同的事件類型驅(qū)動(dòng)頁(yè)面上狀態(tài)卡片和圖片容器的更新。這里有個(gè)后端細(xì)節(jié)要注意SSE 的響應(yīng)頭必須設(shè)成Content-Type: text/event-stream并且關(guān)閉代理層的緩沖。我們第一次上線時(shí)Nginx 默認(rèn)開了緩沖SSE 消息全部攢著不發(fā)前端一直停留在“理解中”排查了很久才找到原因。4.2 Web 端交互工作臺(tái)的實(shí)現(xiàn)Web 工作臺(tái)的界面不算復(fù)雜但狀態(tài)管理很容易亂。我把頁(yè)面拆成了四個(gè)核心模塊左側(cè)圖片列表展示當(dāng)前會(huì)話里所有上傳圖片和生成結(jié)果中間畫布區(qū)展示當(dāng)前編輯的圖片支持縮放和比例標(biāo)注右側(cè)工具信息面板展示 Agent 執(zhí)行的工具序列和每一步的執(zhí)行結(jié)果底部指令輸入框接收用戶自然語(yǔ)言修圖指令支持語(yǔ)音轉(zhuǎn)文字狀態(tài)管理使用 Pinia。整個(gè)會(huì)話上下文全部放在 store 里包括 session_id、image_list、execution_trace、is_processing 等字段。每次 SSE 事件到達(dá)時(shí)通過 store 的 action 統(tǒng)一更新狀態(tài)。組件層只負(fù)責(zé)渲染不做數(shù)據(jù)邏輯。組件通信層級(jí)要注意一個(gè)細(xì)節(jié)Agent 的執(zhí)行軌跡execution_trace是一個(gè)數(shù)組包含每一步的 tool_name、status、耗時(shí)、輸出圖片 URL。這個(gè)數(shù)組要始終在頁(yè)面上可見這樣用戶才能理解 AI 為什么做了某些操作。如果只展示最終結(jié)果用戶會(huì)對(duì)“AI 是不是偷偷改了別的地方”產(chǎn)生疑慮這類工具需要的是過程和結(jié)果都透明。4.3 移動(dòng)端 Uniapp 的適配難點(diǎn)移動(dòng)端用 Uniapp 實(shí)現(xiàn)功能上做了精簡(jiǎn)只保留了上傳圖片、發(fā)送指令、查看執(zhí)行進(jìn)度、查看結(jié)果、保存到相冊(cè)這幾個(gè)操作。核心業(yè)務(wù)邏輯從 Web 端抽取到公共的 JS 模塊里兩個(gè)端共享。實(shí)際開發(fā)中主要遇到兩類差異。第一是文件上傳方式。Web 端可以直接用 FormData 上傳 Blob移動(dòng)端則需要用 uni.uploadFile涉及臨時(shí)文件路徑的轉(zhuǎn)換。第二是圖片預(yù)覽方式。Web 端可以渲染任意 CDN 地址的圖片但移動(dòng)端對(duì)某些 URL 有跨域限制需要統(tǒng)一走代理或加時(shí)間戳簽名。我選用的做法是所有端前端在調(diào)用 Agent 之前都會(huì)對(duì)圖片做一次前端預(yù)壓縮統(tǒng)一將最長(zhǎng)邊壓縮到 2048 像素內(nèi)。這樣既減小了上傳體積也避免了同一個(gè)圖片在不同端處理時(shí)尺寸不一致導(dǎo)致的 Agent 判斷偏差。4.4 數(shù)據(jù)庫(kù)表設(shè)計(jì)和狀態(tài)流轉(zhuǎn)數(shù)據(jù)庫(kù)用的是 MySQL一共有五張核心表用戶、會(huì)話、消息、圖片、任務(wù)記錄。這里重點(diǎn)說(shuō)下任務(wù)表和消息表的設(shè)計(jì)。任務(wù)表記錄了每次 Agent 執(zhí)行的整體信息核心字段包含task_id、session_id、status、current_stage、error_message、completed_at。剛開始我并沒有單獨(dú)設(shè)計(jì)任務(wù)表而是把執(zhí)行狀態(tài)直接存在會(huì)話表里。但很快就發(fā)現(xiàn)一個(gè)問題一個(gè)會(huì)話可能包含多輪修圖操作比如用戶先讓人像美顏再讓改背景這兩個(gè)是不同的任務(wù)如果狀態(tài)都寫在會(huì)話上就會(huì)出現(xiàn)互相覆蓋。消息表的作用是保留 Agent 的完整對(duì)話記錄也就是每條用戶指令和 Agent 的中間決策過程。它有一個(gè) tool_logs 字段類型是 JSON會(huì)記錄每一輪工具調(diào)用時(shí)的輸入?yún)?shù)和返回結(jié)果。開發(fā) Agent 類項(xiàng)目時(shí)沒有這些日志幾乎沒法調(diào)試你很難知道 Agent 當(dāng)時(shí)為什么要做某個(gè)操作。狀態(tài)流轉(zhuǎn)上我定義了一套完整的狀態(tài)機(jī)PENDING等待受理→ RUNNINGAgent 執(zhí)行中→ SUCCEEDED完成 PENDING → RUNNING → FAILED失敗任務(wù)超過 60 秒沒有任何狀態(tài)更新時(shí)服務(wù)端會(huì)強(qiáng)制把狀態(tài)置成 FAILED 并向客戶端推送超時(shí)事件。這是為了防止極端情況下的假死連接。4.5 聯(lián)調(diào)過程中如何做端到端驗(yàn)證端到端驗(yàn)證這個(gè)環(huán)節(jié)我建議做成自動(dòng)化腳本不要每次靠人工點(diǎn)頁(yè)面測(cè)。我們寫了一個(gè)小的 Go 腳本直接模擬客戶端流程上傳圖片、創(chuàng)建會(huì)話、提交任務(wù)、接受 SSE 事件、校驗(yàn)最終圖片 URL 是否返回 200。跑通之后再打開頁(yè)面驗(yàn)證視覺交互。實(shí)際聯(lián)調(diào)時(shí)最容易忽略的是圖片在跨環(huán)境傳輸后的狀態(tài)。同一張圖在本地測(cè)試是正常的但上傳到 OSS 之后可能因?yàn)樵L問權(quán)限、防盜鏈、CORS 配置等原因?qū)е?Agent 后端無(wú)法正常拉取圖片。所以我在服務(wù)端專門寫了一個(gè)“圖片預(yù)檢”環(huán)節(jié)每次 Agent 收到圖片 URL 后會(huì)先做一次 HEAD 請(qǐng)求確認(rèn)可訪問如果不可訪問就直接失敗并提示重新上傳。5. 常見問題與排查技巧實(shí)錄5.1 Agent 無(wú)限循環(huán)調(diào)用工具怎么辦這個(gè)我前面提到過總輪次上限但實(shí)際項(xiàng)目中還有一種更隱蔽的情況雖然單輪不會(huì)卡死但模型會(huì)在相鄰的兩輪之間反復(fù)橫跳。比如先調(diào)用了一次背景替換發(fā)現(xiàn)結(jié)果不滿意又調(diào)用了一次恢復(fù)原圖的工具然后再次調(diào)背景替換形成一個(gè)死循環(huán)。我在提示詞和代碼里做了雙重防護(hù)。提示詞里增加了“如果你發(fā)現(xiàn)自己即將重復(fù)執(zhí)行已經(jīng)做過的操作說(shuō)明當(dāng)前工具結(jié)果不滿足預(yù)期應(yīng)該直接返回 error 或 done”。代碼里則增加了“工具調(diào)用指紋”去重如果 Agent 在最近 3 輪里對(duì)同一個(gè)圖片、同一個(gè)工具、相似參數(shù)執(zhí)行了 2 次系統(tǒng)會(huì)自動(dòng)攔截并詢問是否需要中止。5.2 SSE 連接總是掛斷進(jìn)度推送不穩(wěn)定SSE 掛斷的問題后來(lái)定位出來(lái)原因有三層。第一層是需要心跳機(jī)制。代理層如果沒有數(shù)據(jù)流動(dòng)空閑連接會(huì)被斷開。所以我每 15 秒發(fā)一條event: heartbeat的注釋消息保持連接活躍。第二層是代理層的緩沖。必須在中間件或 Nginx 明確關(guān)閉 response buffering否則數(shù)據(jù)到達(dá)不了瀏覽器。第三層是內(nèi)存泄漏。有些版本的 Golang SSE 庫(kù)在廣播事件時(shí)如果不處理客戶端斷開的 channel會(huì)導(dǎo)致 goroutine 堆積。穩(wěn)定以后我把整條 SSE 推送鏈路做了本地壓力測(cè)試。模擬 50 個(gè)并發(fā)連接同時(shí)在線觀察 10 分鐘確認(rèn)沒有 goroutine 泄漏后才放心上線。5.3 大模型返回 JSON 偶爾解析失敗解析失敗是 Agent 開發(fā)中最常見的坑。大模型返回的內(nèi)容里偶爾會(huì)混入額外的解釋文字或者 JSON 外層包了 Markdown 代碼塊。最開始我直接對(duì)原字符串做 json.Unmarshal失敗率很高后來(lái)做了三層解析容錯(cuò)。第一層是做字符串清洗去掉所有 Markdown 標(biāo)記和第一個(gè)花括號(hào)之前的所有字符。第二層是嘗試自動(dòng)修復(fù)比如未閉合的數(shù)組和對(duì)象標(biāo)記。第三層是解析失敗時(shí)把錯(cuò)誤信息回傳給大模型讓它自己修正輸出。這一步效果非常明顯連續(xù)重試兩次后解析失敗率幾乎降到了零。有一個(gè)值得注意的細(xì)節(jié)給大模型回傳錯(cuò)誤信息時(shí)要明確告訴它“上一次的返回中包含了多余的文本請(qǐng)只返回 JSON不要其他內(nèi)容”否則它很容易繼續(xù)沿襲上一次的錯(cuò)誤格式。5.4 圖片方向、格式和 EXIF 問題的坑手機(jī)上傳的圖片普遍存在 EXIF 反轉(zhuǎn)問題。就是說(shuō)用戶拍照時(shí)手機(jī)是橫著的但圖片本身經(jīng)過旋轉(zhuǎn)處理后才正常顯示。如果后端拿到圖片后直接做處理出來(lái)的結(jié)果可能是橫的或者顛倒的。這個(gè)問題在 Web 端偶爾出現(xiàn)在移動(dòng)端幾乎百分百觸發(fā)。后來(lái)我在所有圖像工具的第一道處理里加了一個(gè)“圖像標(biāo)準(zhǔn)化”流程讀取 EXIF 信息、根據(jù) Orientation 字段做旋轉(zhuǎn)矯正、統(tǒng)一轉(zhuǎn)成 RGB 三通道、刪除透明通道、將最長(zhǎng)邊縮放到 2048px。做完標(biāo)準(zhǔn)化之后所有下游模型拿到的都是規(guī)格一致的圖片模型因?yàn)檩斎氤叽绮灰恢乱l(fā)的隨機(jī)問題少了很多。5.5 不同模型對(duì)輸入和輸出的要求不一致本地 Stable Diffusion 模型和商業(yè) API 的輸入限制差異很大。有的模型要求圖片最小邊長(zhǎng)不低于 512px有的模型又限制最大解析度。圖像標(biāo)準(zhǔn)化流程解決了大部分問題但還是有極少數(shù)圖片處理完以后生成的圖片分辨率過低。我們?cè)谀P途W(wǎng)關(guān)層為每個(gè)模型配置了“輸入約束”和“輸出約束”。網(wǎng)關(guān)在調(diào)用模型前會(huì)根據(jù)約束做一次檢查比如判斷當(dāng)前圖片的寬度是否低于模型最低要求低了就用智能超分先放大確保模型輸入符合預(yù)期。5.6 移動(dòng)端真機(jī)測(cè)試時(shí)才發(fā)現(xiàn)的內(nèi)存暴漲問題這個(gè)坑是在移動(dòng)端出現(xiàn)的。Uniapp 在真機(jī)上連續(xù)處理幾張 2048px 的大圖時(shí)內(nèi)存占用一路飆升最后直接白屏。排查發(fā)現(xiàn)是在圖片預(yù)覽組件里沒有釋放歷史圖片資源。后來(lái)我們把移動(dòng)端的圖片預(yù)覽全部改為懶加載模式并且每次切換圖片時(shí)手動(dòng)回收不再可見的圖片對(duì)象。真機(jī)調(diào)試這個(gè)事情經(jīng)驗(yàn)就是一定要準(zhǔn)備一臺(tái)中低端 Android 手機(jī)做測(cè)試很多性能問題在高端機(jī)上根本復(fù)現(xiàn)不出來(lái)。最后的一點(diǎn)體會(huì)這個(gè)項(xiàng)目最大的收獲不是模型調(diào)得多好而是深刻理解了 Agent 工程化到底是做什么模型負(fù)責(zé)理解和生成但工程負(fù)責(zé)邊界、容錯(cuò)和確定性。用戶能不能穩(wěn)定地用上這個(gè)功能取決于你給 Agent 畫了多清晰的邊界、做了多完善的兜底而不是模型本身有多聰明。最后分享一個(gè)個(gè)人偏好開發(fā)這種帶狀態(tài)和循環(huán)的系統(tǒng)一定要把所有 Agent 決策日志落庫(kù)。我們最初只把最終結(jié)果存下來(lái)了結(jié)果模型一跑偏很難判斷是哪個(gè)環(huán)節(jié)出了問題只能靠猜。后來(lái)把每一輪的原始輸入輸出、工具調(diào)用參數(shù)、異常信息全部記錄下來(lái)調(diào)試效率直接翻倍。以后再做 Agent 類項(xiàng)目我會(huì)把日志體系當(dāng)成基礎(chǔ)設(shè)施一樣在項(xiàng)目第一天就搭好。這個(gè)項(xiàng)目做完以后我還在想能不能把任務(wù)編排的邏輯抽出來(lái)做一個(gè)通用的 Agent 工作流腳手架到時(shí)候再跟大家分享。