用結(jié)構(gòu)樣本:AI生成代碼不跑偏的關(guān)鍵)
先說一個我踩過的坑。幾個月前我用人工智能編程工具寫一個待辦任務模塊需求描述寫得很清楚創(chuàng)建待辦、列表查詢、標記完成、刪除。生成出來的代碼單看每個方法都沒問題真跑起來卻發(fā)現(xiàn)一堆結(jié)構(gòu)性問題——Controller里塞了狀態(tài)判斷Service直接寫SQLRepository上方憑空多出一個“查詢所有任務后在內(nèi)存里排序”的邏輯。后來我換了輸入方式先給AI一份方法調(diào)用結(jié)構(gòu)樣本再讓它按樣本生成。代碼質(zhì)量立刻提升了一個檔次。這件事讓我意識到一個關(guān)鍵點——所謂的人工智能編程真正決定產(chǎn)出上限的往往不是提示詞寫得多花哨而是你有沒有給AI提供一份清晰的方法調(diào)用結(jié)構(gòu)樣本。這篇文章就圍繞這個主題展開方法調(diào)用結(jié)構(gòu)樣本到底是什么、待辦任務場景下怎么設計、按什么姿勢喂給AI、實際踩過哪些坑。適合正在用AI寫業(yè)務代碼的開發(fā)者也適合想在團隊里統(tǒng)一AI生成代碼風格的讀者參考。1. 為什么AI編程離不開方法調(diào)用結(jié)構(gòu)樣本1.1 一次待辦任務代碼的“翻車現(xiàn)場”我最初用的提示詞大概是這樣的“幫我寫一個待辦任務模塊包含創(chuàng)建、列表、完成、刪除功能用Spring Boot實現(xiàn)?!毙枨蟛豢芍^不清楚AI也不負眾望地生成了一大堆代碼。但你把構(gòu)造函數(shù)一拉開問題全出來了Controller里的createTodo方法寫了完整的參數(shù)校驗和狀態(tài)判斷業(yè)務規(guī)則盤踞在HTTP層ApplicationService里沒有業(yè)務編排反而出現(xiàn)了JdbcTemplate直接寫SQLRepository層被繞開部分查詢邏輯散落在Service和Controller里標記完成時直接setStatus(COMPLETED)沒有校驗當前狀態(tài)是否允許流轉(zhuǎn)。單看每個方法語法都沒問題但組合在一起就是典型的“能跑但沒法維護”的結(jié)構(gòu)災難。我當時的第一反應是“AI不行”但冷靜下來復盤發(fā)現(xiàn)問題出在我自己身上——我沒告訴AI這個項目的方法調(diào)用結(jié)構(gòu)是怎么組織的。1.2 AI編程工具的真實工作方式它是模式補全器不是架構(gòu)師市面上主流的AI編程工具本質(zhì)上是基于海量代碼訓練出來的模式補全器。你給它一個指令它根據(jù)訓練數(shù)據(jù)里的統(tǒng)計規(guī)律預測“最有可能的下一段代碼是什么”。注意是最有可能不是最正確更不是最符合你項目架構(gòu)。這意味著什么AI對“正確結(jié)構(gòu)”的理解來自它見過的通用代碼而不是你項目的特定約定。你的項目是Controller → ApplicationService → DomainService → Repository的四層結(jié)構(gòu)AI腦子里卻是五花八門的Controller直接操作DAO、Service既是事務邊界又是業(yè)務邏輯集合的混亂模式。所以它生成的代碼“看起來像那么回事”但細節(jié)全是結(jié)構(gòu)硬傷。方法調(diào)用結(jié)構(gòu)樣本就是用來解決這個上下文缺失問題的。你提前把“這個項目里方法之間應該怎么調(diào)用、每層該干什么、不該干什么”寫清楚AI才有依據(jù)生成符合架構(gòu)的代碼。沒有樣本AI在猜有了樣本AI在按圖施工。1.3 待辦任務最適合當樣本的業(yè)務場景有人可能會問為什么要拿待辦任務做樣本因為它是典型的“麻雀雖小五臟俱全”的業(yè)務場景。待辦任務的功能不多但覆蓋了完整的后端調(diào)用鏈路創(chuàng)建涉及參數(shù)校驗和持久化列表涉及過濾排序分頁標記完成涉及狀態(tài)機和權(quán)限校驗刪除涉及存在性檢查和級聯(lián)約束。這四個操作放在一起基本把Controller、ApplicationService、DomainService、Repository四層能遇到的問題全暴露了一遍。更關(guān)鍵的是待辦任務的業(yè)務規(guī)則足夠簡單容易把“調(diào)用鏈本身”和“復雜業(yè)務”剝離開讓讀者看清楚結(jié)構(gòu)長什么樣。你拿一個訂單系統(tǒng)當樣本光狀態(tài)機就夠?qū)懸黄L文反而沖淡了方法調(diào)用結(jié)構(gòu)這個主題。所以待辦任務是我推薦的首選樣本場景學會了這里的調(diào)用關(guān)系換到訂單、庫存、任務流思路完全一致。2. 待辦任務調(diào)用鏈的骨架設計先定邊界再寫代碼2.1 一個用例對應一個方法而不是按CRUD劃分設計方法調(diào)用結(jié)構(gòu)樣本的第一步不是打開IDE寫代碼而是先把用例邊界劃清楚。待辦任務的標準用例至少有五個創(chuàng)建待辦、查詢待辦列表、查詢待辦詳情、標記待辦完成、刪除待辦。這里最常見的錯誤是“按CRUD劃分方法”。很多AI生成代碼時天然喜歡把“創(chuàng)建”對應insert“列表”對應select“刪除”對應delete一套數(shù)據(jù)操作四板斧。結(jié)果是標記完成這種典型的“狀態(tài)變更用例”被硬生生拆成了“先把記錄查出來再改個狀態(tài)字段再存回去”的散裝代碼。按用例劃分的意思是讓ApplicationService的每個方法完整表達一個業(yè)務用戶故事。createTodo就是“創(chuàng)建一個待辦”從校驗參數(shù)到落庫到返回VO一氣呵成。completeTodo就是“把一個待辦標記完成”從查存在性到校驗狀態(tài)到變更到落庫同樣一氣呵成。一個方法對應一個完整用例調(diào)用鏈清晰AI也好模仿人也好review。2.2 調(diào)用鏈四層職責拆分待辦任務的方法調(diào)用結(jié)構(gòu)我用四層來定義這也是我在團隊里推薦的標準形態(tài)層職責允許做的事禁止做的事ControllerHTTP入口適配參數(shù)解析、調(diào)用ApplicationService、返回VO寫業(yè)務判斷、直接操作實體、訪問RepositoryApplicationService用例編排調(diào)度DomainService和Repository開啟事務寫SQL、拼業(yè)務規(guī)則、直接操作實體內(nèi)部狀態(tài)DomainService領(lǐng)域規(guī)則校驗狀態(tài)流轉(zhuǎn)校驗、權(quán)限校驗、業(yè)務不變量校驗做持久化、編排多個用例、返回值里塞DTORepository持久化適配保存、查詢、刪除、分頁過濾寫業(yè)務規(guī)則、返回組裝好的VO這套劃分不是銀彈但勝在簡單可執(zhí)行AI照著樣本不容易跑偏。核心思想是每一層只做一件事層與層之間單向依賴不允許往回調(diào)用。拿DomainService舉例它很容易和ApplicationService混淆。我的判據(jù)很簡單ApplicationService管的是“這個用例要經(jīng)過哪些步驟”DomainService管的是“這個領(lǐng)域規(guī)則允不允許”。前者是流程編排者后者是規(guī)則裁判員。裁判員只做判斷不做操作所以DomainService的方法最好返回void校驗不過就拋異常校驗通過就靜靜返回。2.3 方法命名、返回值和異常的硬性約定骨架設計里最容易被忽略但恰恰是AI最需要明確指示的三件事方法怎么命名、返回值給什么、異常怎么拋。方法命名我要求“動詞開頭、表達業(yè)務意圖”。創(chuàng)建待辦就叫createTodo不叫insertTodo標記完成就叫completeTodo不叫updateStatus。命名是調(diào)用鏈的第一層語言命名爛了整個結(jié)構(gòu)就開始松動。AI在這方面的能力比人更差因為它傾向從數(shù)據(jù)視角命名你必須在樣本里替它立好規(guī)矩。返回值方面我的約定是“永不出實體一律出VO”。Controller返回給前端的必須是TodoVO不直接返回Todo實體。貧血模型還是充血模型之爭先放一邊至少對外輸出要保持穩(wěn)定。如果讓AI放飛自我它會在三個不同的方法里分別返回實體、DTO、Mapreview到懷疑人生。異常處理更是一個坑。我要求領(lǐng)域規(guī)則校驗統(tǒng)一拋InvalidTodoException、狀態(tài)沖突拋TodoStateConflictException、未授權(quán)拋TodoPermissionException由全局異常處理器統(tǒng)一映射成HTTP狀態(tài)碼。一切為了不讓AI在Controller里寫try-catch。3. 一份可以直接復用的方法調(diào)用結(jié)構(gòu)樣本3.1 創(chuàng)建待辦任務的調(diào)用鏈樣本這部分我把實際在項目里用的樣本貼出來代碼用Java演示換成TypeScript或Golang只是語法差異調(diào)用結(jié)構(gòu)完全一樣。同時也是這段內(nèi)容的核心——先看完整調(diào)用鏈再怎么拆// #SAMPLE:CREATE_TODO // // 調(diào)用鏈TodoController.createTodo // → TodoApplicationService.createTodo // → TodoDomainService.validateNewTodo // → TodoRepository.save // 約束 // - Controller 只做 HTTP 參數(shù)解析不寫業(yè)務判斷 // - ApplicationService 內(nèi)部不出現(xiàn) if 和校驗邏輯 // - 領(lǐng)域校驗統(tǒng)一放 DomainService // RestController RequestMapping(/api/v1/todos) public class TodoController { private final TodoApplicationService todoApplicationService; public TodoController(TodoApplicationService todoApplicationService) { this.todoApplicationService todoApplicationService; } PostMapping public ResponseEntityTodoVO createTodo(RequestBody TodoCreateRequest request) { TodoCreateCommand command request.toCommand(CurrentUser.id()); TodoVO vo todoApplicationService.createTodo(command); return ResponseEntity.ok(vo); } }Service public class TodoApplicationService { private final TodoDomainService todoDomainService; private final TodoRepository todoRepository; public TodoApplicationService(TodoDomainService todoDomainService, TodoRepository todoRepository) { this.todoDomainService todoDomainService; this.todoRepository todoRepository; } Transactional public TodoVO createTodo(TodoCreateCommand cmd) { Todo todo cmd.toEntity(); todoDomainService.validateNewTodo(todo); todoRepository.save(todo); return TodoVO.from(todo); } }Component public class TodoDomainService { private static final int MAX_TITLE_LENGTH 50; public void validateNewTodo(Todo todo) { if (todo.getTitle() null || todo.getTitle().trim().isEmpty()) { throw new InvalidTodoException(待辦標題不能為空); } if (todo.getTitle().length() MAX_TITLE_LENGTH) { throw new InvalidTodoException(標題長度不能超過50個字符); } if (todo.getDeadline() ! null todo.getDeadline().isBefore(LocalDateTime.now())) { throw new InvalidTodoException(截止時間不能早于當前時間); } } }public interface TodoRepository { void save(Todo todo); OptionalTodo findById(Long id); ListTodo findByUserId(Long userId, Pageable pageable); ListTodo findByUserIdAndStatus(Long userId, TodoStatus status, Pageable pageable); void delete(Todo todo); }這段樣本覆蓋了完整鏈路。留意幾個細節(jié)Controller連命令對象都不自己組裝而是讓Request的toCommand方法完成ApplicationService里沒有任何if校驗完全委托給DomainServiceRepository接口在樣本里一并寫好為的是堵住AI“發(fā)明新方法”的嘴。3.2 標記完成與刪除的調(diào)用鏈樣本創(chuàng)建是新增相對簡單。標記完成是狀態(tài)變更調(diào)用鏈要復雜一截因為它多了一個“先查后改”的過程// #SAMPLE:COMPLETE_TODO // 調(diào)用鏈TodoController.completeTodo // → TodoApplicationService.completeTodo // → TodoRepository.findById先查存在性 // → TodoDomainService.validateCompletion再校驗規(guī)則 // → todo.markCompleted()執(zhí)行狀態(tài)變更 // → TodoRepository.save最后落庫 PatchMapping(/{id}/complete) public ResponseEntityTodoVO completeTodo(PathVariable Long id, RequestBody TodoCompleteRequest request) { TodoCompleteCommand command new TodoCompleteCommand( id, CurrentUser.id(), request.getCompletedAt()); TodoVO vo todoApplicationService.completeTodo(command); return ResponseEntity.ok(vo); }Transactional public TodoVO completeTodo(TodoCompleteCommand cmd) { Todo todo todoRepository.findById(cmd.getTodoId()) .orElseThrow(() - new TodoNotFoundException(cmd.getTodoId())); todoDomainService.validateCompletion(todo, cmd.getUserId()); todo.markCompleted(cmd.getCompletedAt()); todoRepository.save(todo); return TodoVO.from(todo); }public void validateCompletion(Todo todo, Long userId) { if (!todo.belongsTo(userId)) { throw new TodoPermissionException(無權(quán)操作他人的待辦任務); } if (todo.getStatus() TodoStatus.COMPLETED) { throw new TodoStateConflictException(該待辦已完成不能重復操作); } if (todo.getStatus() TodoStatus.CANCELLED) { throw new TodoStateConflictException(已取消的待辦不能標記完成); } }刪除操作的調(diào)用鏈和完成操作很接近deleteTodo同樣先findById查存在性再做歸屬和狀態(tài)校驗最后調(diào)TodoRepository.delete(todo)。沒必要整段貼出來但要注意刪除也要走“先查后刪”不能讓AI直接寫deleteById(id)了事否則把別人的待辦刪了都沒人知道。3.3 列表查詢鏈路與狀態(tài)流轉(zhuǎn)約束查詢鏈路和寫入鏈路不一樣寫入是“實例方法調(diào)用序列”查詢更接近“參數(shù)傳遞鏈”。但同樣有結(jié)構(gòu)約束// #SAMPLE:LIST_TODOS // 查詢鏈路TodoController.listTodos // → TodoApplicationService.listTodos // → TodoRepository.findByUserIdAndStatus // → 組裝 TodoVO // 約束排序、分頁、過濾全部在 Repository 完成 // 禁止把全量數(shù)據(jù)拉到內(nèi)存再過濾 GetMapping public ResponseEntityPageResultTodoVO listTodos( RequestParam(required false) TodoStatus status, RequestParam(defaultValue 0) int page, RequestParam(defaultValue 20) int size) { PageResultTodoVO result todoApplicationService.listTodos( CurrentUser.id(), status, page, size); return ResponseEntity.ok(result); }public PageResultTodoVO listTodos(Long userId, TodoStatus status, int page, int size) { Pageable pageable PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, createdAt)); ListTodo todos; if (status null) { todos todoRepository.findByUserId(userId, pageable); } else { todos todoRepository.findByUserIdAndStatus(userId, status, pageable); } return PageResult.from(todos.stream().map(TodoVO::from).toList()); }查詢鏈路樣本里最重要的一條約束是“不要在內(nèi)存里過濾和排序”。AI特別容易在Repository沒有提供對應方法時直接findByUserId拿到全量列表再在Service里用Stream做過濾排序。數(shù)據(jù)量小的時候沒問題一旦單用戶待辦破千這種寫法就是性能事故。所以樣本里必須寫死過濾排序分頁是Repository的職責。狀態(tài)流轉(zhuǎn)約束是待辦任務里最容易丟的一環(huán)。我把這張狀態(tài)機表也放進樣本當前狀態(tài)允許到達狀態(tài)觸發(fā)操作NEWIN_PROGRESS開始任務NEWCOMPLETED直接完成NEWCANCELLED取消任務IN_PROGRESSCOMPLETED標記完成IN_PROGRESSCANCELLED取消任務COMPLETED無終態(tài)CANCELLED無終態(tài)4. 把樣本正確喂給AI的三種姿勢4.1 對話式AI樣本前置 明確指令很多人用對話式AI編程的習慣是直接說需求這其實是把AI當成了“懂項目背景的老同事”。正確姿勢是把需求描述和項目約定分開先給項目約定再給需求。我實際用的提示詞結(jié)構(gòu)是這樣的你是一位熟悉分層架構(gòu)的Java工程師。項目使用 Controller → ApplicationService → DomainService → Repository 四層結(jié)構(gòu)方法調(diào)用約束如下 [此處粘貼上文的方法調(diào)用結(jié)構(gòu)樣本包括三個樣本代碼反例約定] 請按照以上調(diào)用鏈結(jié)構(gòu)實現(xiàn)“編輯待辦標題”功能。 編輯規(guī)則標題不能為空長度不超過50字符只能編輯自己的待辦。注意順序樣本在前需求在后。這份提示詞還有一個隱藏作用——當需求描述和樣本矛盾時AI會優(yōu)先遵守約束更強的指令而不是放飛想象力。4.2 IDE輔助編程樣本目錄與符號標記在Cursor、Copilot這類IDE工具里把全部樣本堆在提示詞里不現(xiàn)實更推薦的方式是把樣本做成項目里的“可檢索文檔”。我在項目根目錄維護了一個docs/ai-patterns/目錄里面放method-call-patterns.md核心代碼塊統(tǒng)一用#SAMPLE:CREATE_TODO、#SAMPLE:COMPLETE_TODO這類大寫標簽開頭。這樣做有兩個好處。第一編輯器里的AI補全能通過注釋感知項目約定你在寫createTodo時它會從歷史代碼和樣本注釋里領(lǐng)悟調(diào)用風格。第二用Cursor的對話功能時可以直接這個文件告訴AI“先讀這個文件再干活”。4.3 輸入輸出對和反例標注只給“正向樣本”還不夠AI會把正向樣本當成“參考風格”但它并不知道哪些做法是紅線。所以我的樣本文件里專門有一節(jié)叫“反例約定”明文禁止四種寫法// 反例約定以下代碼模式禁止出現(xiàn)在本項目中 // 1. Controller 里出現(xiàn) if 判斷業(yè)務狀態(tài) // 2. ApplicationService 里出現(xiàn) SQL/JdbcTemplate // 3. Service 之間互相調(diào)用內(nèi)部方法繞過 ApplicationService // 4. Repository 返回 List 后在 Service 內(nèi)存中做過濾/排序/分頁經(jīng)驗是AI對“禁止做什么”的遵守度遠高于“應該做什么”。人也一樣給一個明確禁止清單比對著一堆代碼猜隱含規(guī)則要高效得多。4.4 樣本也要納入版本管理樣本文件不是一次性產(chǎn)物它必須跟著項目架構(gòu)演進。我見過最典型的場景是項目把Repository從MyBatis換成了Spring Data JPA但樣本文件沒更新AI繼續(xù)按老接口生成代碼編譯都過不了。所以凡是涉及調(diào)用鏈調(diào)整的PR都要同步改method-call-patterns.md。這和我把樣本當“團隊編碼憲法”的理念是一致的——它是活的約定不是存檔的筆記。我在提交記錄里甚至會寫“doc: 更新方法調(diào)用結(jié)構(gòu)樣本新增軟刪除鏈路”讓整個團隊都能追溯架構(gòu)約定的變化。5. AI按樣本生成代碼時的典型偏差與修正5.1 偏差一方法拆分粒度不對即使給了樣本AI還是會在細節(jié)上跑偏。最常見的是方法拆分粒度問題要么把ApplicationService的多個用例塞進一個方法要么把一個用例拆得支離破碎比如把校驗寫成一個私有方法、把VO轉(zhuǎn)換寫成另一個私有方法、真正的業(yè)務邏輯只剩兩三行。識別方法很清楚看方法體里是不是超過30行、方法參數(shù)是不是超過4個、方法命名是不是變成了processTodoData之類。修正方法也很簡單讓AI“分步驟生成不要一次性輸出完整文件”。我通常讓它先輸出ApplicationService方法骨架再補DomainService校驗規(guī)則最后寫Repository方法每步之間人工看一眼。5.2 偏差二AI自己“發(fā)明”Repository方法這是最隱蔽的一個坑。AI需要“按標題模糊搜索待辦”時如果樣本里沒有findByTitleContaining它會很自然地在Repository接口里新增這個方法而不是復用已有的findByUserId。如果項目用的是JPAAI甚至會發(fā)明一個映射關(guān)系完全不存在的查詢方法編譯期才發(fā)現(xiàn)錯誤。更嚴重的是它會引入findAll()然后把過濾邏輯寫在Service里。剛才說了這是查詢鏈路的紅線。我的處理方式是樣本里加一句硬約束“Repository接口方法以樣本為準AI不得新增必須新增時先詢問用戶。”這句話看著簡單實戰(zhàn)里能省下大半review時間。5.3 偏差三事務邊界跑偏事務是AI最理解不了的機制之一。它經(jīng)常把Transactional加到DomainService上或者加到ApplicationService里一個被同類的其他方法調(diào)用的私有方法上事務直接失效。我吃過一次大虧讓AI生成“批量完成待辦”功能時AI在TodoApplicationService.batchComplete里調(diào)用了completeTodo方法但因為兩個方法都在同一個類里completeTodo被同類內(nèi)部調(diào)用時繞過了代理對象事務注解根本沒生效。數(shù)據(jù)寫了一半。所以樣本里我固定了一條鐵律事務只標注在ApplicationService的公開用例方法上DomainService和Repository一律不標注。內(nèi)部方法調(diào)用一律通過注入的獨立Bean進行。5.4 偏差四繞過領(lǐng)域方法直接改狀態(tài)這個偏差最隱蔽也最致命。AI生成了todo.setStatus(TodoStatus.COMPLETED)就直接落庫了完全跳過了todo.markCompleted()里的狀態(tài)機校驗邏輯。結(jié)果就是一個CANCELLED狀態(tài)的任務可以被直接標記成COMPLETED業(yè)務上毫無意義還污染數(shù)據(jù)。我會在樣本的實體示例代碼里明確寫出實體的內(nèi)部狀態(tài)變更方法public void markCompleted(LocalDateTime completedAt) { // 狀態(tài)流轉(zhuǎn)只在這里做 if (this.status ! TodoStatus.NEW this.status ! TodoStatus.IN_PROGRESS) { throw new TodoStateConflictException(當前狀態(tài)不能標記完成); } this.status TodoStatus.COMPLETED; this.completedAt completedAt; }配合“Repository保存的必須是狀態(tài)變更完成后的實體”這條約定AI基本不會再來硬改實體內(nèi)部字段。5.5 實測下來最有效的修正口訣經(jīng)過一段時間磨合我形成了一套最有效的修正檢查每次AI生成完代碼我都會跑一遍快速掃描搜索setStatus和setTitle這種直接setter確認沒有繞過實體方法搜索new JdbcTemplate和jdbcTemplate確認Service層沒寫SQL數(shù)一數(shù)Controller里有沒有if關(guān)鍵字有就多半是業(yè)務邏輯泄漏確認所有Transactional都在ApplicationService的方法上??谠E就四句先查后改校驗在前倉儲接口不新增事務只寫在用例方法上。這十六個字基本上把AI在方法調(diào)用結(jié)構(gòu)上90%的偏差都堵住了。最后再分享一個我個人的使用習慣在項目的README里加一行鏈接指向docs/ai-patterns/method-call-patterns.md讓任何新加入項目的人以及任何AI編程工具都第一眼能看到這份樣本。每當架構(gòu)有變動先改樣本再讓AI生成代碼。這個順序一旦固定下來AI生成代碼的返工率會肉眼可見地下降。希望這篇關(guān)于方法調(diào)用結(jié)構(gòu)樣本的拆解能讓你在下次用AI寫待辦任務或者任何類似業(yè)務模塊時少踩幾個我已經(jīng)替你踩過的坑。