最近在帮团队搭一个内部 AI 服务需求评审的时候被问了一句“你们是用低代码平台拖流程还是直接写代码”我当时的回答很干脆写代码。后来想想这个决定背后其实有一套完整的逻辑——“灵活、代码可控”这是构建 AI 应用BuildingAI这件事最该放在第一位的地基。先说结论如果你的 AI 应用要做进真实业务要接内部系统要面对各种异常输入那“代码可控”不是偏好问题是生死问题。拖拽式平台能让你 1 小时做出 Demo但代码能让你 100 小时之后还能正常运维。这篇文章我打算从设计思路、分层架构、实操骨架、踩坑实录四个角度把“怎么用代码把 AI 应用建得灵活又可控”这件事讲透。1. 为什么“代码可控”才是构建 AI 应用的底层需求1.1 可视化搭建平台绕不开的三个天花板先说清楚我不是否定低代码平台。它们在你做原型验证、内部小工具、快速给老板演示的时候确实快得离谱。但一旦进入正式业务三个问题就会像天花板一样压下来。第一个问题是调试成本。拖拽流程里出了问题你看到的是“第 3 个节点执行失败”但到底是大模型返回了畸形 JSON还是上游接口超时还是某个变量的值被意外覆盖这些信息在可视化界面里天然难定位因为没有调用栈没有断点日志又往往过于抽象。我在实际项目里遇到过一次一个拖拽工作流在某个用户输入下反复出错平台日志只给了一行“Tool execution failed”最后没办法只能手动把那一整段流程翻译成代码5 分钟定位到了问题——是正则表达式没处理多字节空格。如果当初直接用代码写这个坑根本不会活过 Code Review。第二个问题是版本管理。可视化流程本质上是一堆元数据两个分支之间的差异对比几乎不可读。你要做 Code Review、要回滚到上周的版本、要给不同的环境同步流程配置拖拽平台给你提供的往往只是一个“导出 JSON”按钮而这个 JSON 一旦复杂起来人眼根本没法审查。但在代码仓库里一切改动都是文本 diff谁改了 prompt、谁加了参数、谁动了温度系数清清楚楚。第三个问题是复杂编排能力。真实的业务场景里条件分支可能嵌套三层并行任务可能要合并结果循环可能要根据上一步的输出动态决定次数。这类逻辑用代码写只是一个if加一个for在拖拽平台里却要把图结构画得密密麻麻维护成本直接爆炸。所以我的判断很明确低代码适合线性流程代码适合有生命力的系统。真正的生产级 AI 应用一定是后者。1.2 “代码可控”到底控制的是哪四样东西之所以强调“代码可控”是因为大模型应用有一个独特的问题不确定性是常态。传统软件你调一个函数输入相同输出必然相同大模型不同同样的 prompt 配上不同的温度每次回答都有概率不一样。这种不确定性不是 bug是特性但它必须被“管住”。用代码构建 AI 应用本质上是在管理四件事第一数据流。每一步的输入是什么、输出是什么、存到了哪里代码里一目了然。你可以明确地定义“用户输入”经过清洗后变成cleaned_input拼进 prompt 后变成messages大模型返回后经过校验变成result每一步都是显式的变量不再是流程节点之间的黑盒连线。第二运行状态。调用大模型可能失败工具函数可能超时Agent 可能陷入循环。代码让你能做细颗粒度的控制失败了几次就重试、重试间隔多长、整个流程最多跑多少秒、超时了是降级还是直接报错。这些精细控制靠平台自带的“重试 3 次”开关根本做不到。第三成本。大模型应用的钱花在 token 上而 token 消耗跟 prompt 设计、上下文管理、重试次数直接相关。用代码写你能精确把握每次请求发送了哪些内容、历史记录怎么裁剪、哪些中间结果需要缓存。我做过的项目里单单是一个“上下文按需截断”的策略就把单次会话成本降了 60%。第四行为边界。生产环境的 AI 要连数据库、调接口你不可能把系统 Prompt 里写一句“不要执行危险操作”就完事。代码里你能控制工具函数的权限级别能在调用链路上加鉴权和审计能对模型输出做敏感词过滤和格式校验。这些东西只有代码能做到“有理有据、可审计”。2. BuildingAI 的核心设计思路先分层再编排2.1 四层架构跟写后端服务一个套路我见过很多 AI 应用项目最大的问题是所有逻辑揉成一团——prompt 写在业务函数里模型调用散落各处工具函数直接操作数据库。这样写的后果是业务逻辑调整的时候连带模型参数一起改模型升级的时候业务逻辑被无辜牵连。正确做法是用后端服务的老经验分层。我把 AI 应用从下往上拆成四层每一层只解决一个问题。模型接入层在最底下负责屏蔽不同大模型提供方的差异。无论你用的是 OpenAI 的接口、国内厂商的兼容接口还是开源的本地模型在这层统一成一个接口向外只暴露complete(messages, params)这样的方法。好处是换模型厂商只改一层加日志、加重试、加 token 统计也全在这一层做掉上层完全不感知。工具函数层再往上把“AI 能做的事”抽象成一个个带清晰描述的普通函数查询订单、计算运费、生成工单编号、查物流轨迹。每个函数只做一件事输入输出都有明确定义。这一层和 LLM 没有任何关系你完全可以给它们写单元测试。业务服务层负责把“模型输出”变成“业务操作”。比如模型判断用户想查订单业务服务层就负责调用查询函数、格式化结果、决定要不要追问用户。这一层是确定性的代码不依赖模型。流程编排层在最上面像一个导演——它决定什么时候调用模型、什么时候调用工具、什么时候停止。在代码里这一层通常是一个while循环加一个状态机后面实操部分我会给出一个具体实现。2.2 把 LLM 当“实习生”别当“数据库”设计这套分层架构时我心里始终记着一条原则LLM 只负责“理解”和“生成”不负责“记忆”和“决策”。具体说就是用户之前说过什么不要指望模型记住要由你的代码把历史消息作为上下文传进去系统有哪些状态、执行到哪一步了不要交给模型自由发挥要在代码里用一个显式变量维护工具函数调用的参数要按 JSON Schema 严格校验不能信任模型的输出。说白了LLM 是一个能力极强的实习生——他能听懂你的话能帮你干活但你得把任务分解清楚、把边界划明白、把每一步的结果都检查一遍。这个定位直接影响技术选型。比如要让 AI 具备“记忆”不是在 system prompt 里写“请记住用户的名字”而是把用户信息写入数据库下次对话时由代码查出并注入上下文。再比如要让 AI 能做多步推理不是靠模型自己“灵光一现”而是用 Function Calling 一步步引导它调用工具、获取信息、再继续推理。代码是大脑的骨架模型只是骨架上的肌肉。这套设计还有一个额外好处可测试性大幅提升。模型接入层是稳定的接口工具函数层是纯逻辑业务服务层是状态转换每一层都能写单元测试只有最上层的编排逻辑里会出现有限的“模型不确定性”。把不确定的部分隔离在一个可控范围这正是代码可控的本质。3. 实操从零搭一个代码可控的 AI 应用骨架3.1 工程初始化与依赖选型下面的实操以 Python 为例Python 在大模型生态里支持最完整示例代码我尽量精简但保持了生产可用的结构。建议用uv管理依赖比pip快很多锁文件也更可靠。初始化一个项目mkdir building-ai-demo cd building-ai-demo uv init uv add openai pydantic python-dotenv三个依赖的职责很明确openai是官方 SDK不管底层连哪家模型只要兼容协议就能用pydantic用来做结构化输出的 Schema 校验python-dotenv读环境变量把 API Key 跟代码隔离。目录结构我习惯这样组织building-ai-demo/ ├── app/ │ ├── llm/ │ │ ├── client.py # 模型接入层 │ │ └── prompts.py # Prompt 模板管理 │ ├── tools/ │ │ ├── registry.py # 工具注册表 │ │ └── order.py # 业务工具函数 │ ├── services/ │ │ └── order_service.py # 业务服务层 │ ├── agent/ │ │ └── loop.py # Agent 编排主循环 │ ├── schemas.py # 输入输出 Schema │ └── main.py # 入口 ├── .env # 环境变量不入库 └── pyproject.toml从目录就能看出四层架构的影子。模块职责单一每一层都只依赖它下面的层。注意.env文件一定要加进.gitignore。我见过不止一个项目把 API Key 提交进 Git 仓库识别出来之后全组加班轮换密钥这属于核弹级的低级失误。3.2 模型接入层让上层永远不知道“换模型”这件事模型接入层要做的事是统一封装 Chat Completion 接口。我不建议直接在各处调用 SDK而是全部走一个LLMClient# app/llm/client.py import logging import time from typing import Any from openai import OpenAI from pydantic import BaseModel logger logging.getLogger(__name__) class LLMClient: def __init__(self, api_key: str, base_url: str None, model: str gpt-4o-mini): self.model model self.client OpenAI(api_keyapi_key, base_urlbase_url) def complete( self, messages: list[dict], response_format: type[BaseModel] | None None, temperature: float 0.3, max_retries: int 2, ) - Any: kwargs: dict[str, Any] { model: self.model, messages: messages, temperature: temperature, } if response_format is not None: kwargs[response_format] response_format last_error: Exception | None None for attempt in range(max_retries 1): try: response self.client.chat.completions.create(**kwargs) content response.choices[0].message.content or if response_format is not None: return response_format.model_validate_json(content) return content except Exception as exc: last_error exc logger.warning(LLM 调用失败第 %s 次%s, attempt 1, exc) if attempt max_retries: time.sleep(2**attempt) # 指数退避 raise last_error这个封装有几个细节值得注意。temperature默认设成 0.3。做业务工具类的 AI 应用我不建议默认用高温因为高温意味着随机性随机性在真实业务场景里意味着不可控。默认值设低一点只有确实需要创造性输出写文案、做头脑风暴时才在特定调用里调高。response_format直接用 Pydantic 模型里面我做了一步把模型返回的 JSON 字符串解析并校验成 Pydantic 对象。这个做法的价值在于输出结构不合规时少字段、类型错、多出字段会立刻抛异常而不是让脏数据流到业务层后面走到深水区才爆雷。重试用了指数退避并且把退避时间、错误都记进日志。生产环境里大模型接口偶尔抖动很正常盲目快速重试大概率还会失败退避是性价比最高的策略。这个类用起来很简单llm LLMClient(api_keyos.getenv(OPENAI_API_KEY))后续如果要从 OpenAI 换成国内兼容接口只需要改base_url和model两个参数上层代码零改动。这就是“代码可控”的第一个真实收益供应商不是锁你的枷锁而是可替换的零件。3.3 Prompt 管理与输出 Schema把不确定输出关进笼子Prompt 管理最容易犯的错误是把提示词塞进代码靠字符串拼接。改一个字都要重新发版而且可读性极差。我的做法是集中管理 模板渲染。# app/llm/prompts.py from string import Template ORDER_SYSTEM_PROMPT Template( 你是一个电商客服助手。你的任务是根据用户的提问协助查询订单信息。 可用工具 - get_order_status查询订单状态 - get_order_物流查询物流轨迹 约束 - 只处理与订单、物流相关的问题 - 如果用户没有提供订单号不要编造请回答需要订单号 - 回答保持简洁不要输出与问题无关的内容 当前上下文 ${context} .strip())用Template做渲染比粗暴的f-string安全——用户内容里万一有{}也不会破坏模板结构。另一个习惯是所有用户输入进入 prompt 前统一做一次换行和 JSON 特殊字符转义防止用户通过注入指令扰乱 System Prompt。结构化输出是“代码可控”的核心武器。LLM 返回的是自然语言但业务系统需要字段。我们可以强制要求模型返回合法 JSON并用 Pydantic 定义格式# app/schemas.py from typing import Literal from pydantic import BaseModel, Field class OrderQueryResult(BaseModel): need_tool: bool Field(description是否需要调用工具查询) order_id: str | None Field(defaultNone, description订单号) intent: Literal[查状态, 查物流, 其他] Field(description用户意图) reply: str Field(description给用户的回复文案)有了这个输出 Schema模型的自由发挥空间就被大大压缩了。你要求它返回 JSON并且字段已经定义清楚剩下的只是把回复内容“翻译”成结构化数据。调用方式result llm.complete(messages, response_formatOrderQueryResult)这是一条非常有用的工程经验永远不要直接使用模型返回的原始文本作为业务数据。无论你怎么写 prompt你都必须通过 Pydantic 这类工具做运行时校验。这一步能拦住大量“看起来对但其实不合法”的模型输出。3.4 工具函数注册用 JSON Schema 给 AI 一双“合规的手”要让 AI 真正动手办事靠的是 Function Calling。做法是先定义一批工具函数然后把它们的描述和参数 Schema 传给模型让模型根据用户意图选择调用哪个函数。我习惯把所有工具函数集中登记在一个注册表里方便统一管理和过滤# app/tools/registry.py import inspect import json from typing import Callable TOOL_REGISTRY: dict[str, Callable] {} def register_tool(func: Callable): TOOL_REGISTRY[func.__name__] func return func def get_tool_schemas() - list[dict]: schemas [] for name, func in TOOL_REGISTRY.items(): schema { type: function, function: { name: name, description: inspect.getdoc(func) or , parameters: {type: object, properties: {}}, }, } sig inspect.signature(func) for param_name, param in sig.parameters.items(): schema[function][parameters][properties][param_name] { type: string, description: f参数 {param_name}, } schemas.append(schema) return schemas实际工具函数长这样# app/tools/order.py from app.tools.registry import register_tool register_tool def get_order_status(order_id: str) - str: 查询订单状态。参数 order_id 为订单号如 ORD123456。 # 真实项目里这里会用订单号查数据库或业务接口 return 订单已发货预计 3 天内送达。 register_tool def get_tracking_info(order_id: str) - str: 查询物流轨迹。参数 order_id 为订单号。 return 包裹已到达杭州转运中心下一站为上海分拨中心。这里有个容易被忽略的细节工具函数的 docstring 不是摆设它是模型判断“该不该调用你”的关键依据。模型通过 docstring 理解这个函数是干什么的写得模糊就直接影响调用准确率。inspect动态生成 Schema 只是示例生产环境我更推荐手写显式 JSON Schema尤其是参数类型不只是 string 的时候。参数描述越精确模型填充参数的准确度越高。3.5 Agent 主循环把“AI 判断”变成确定性流程万事俱备最后是核心编排层。Agent 主循环的思想非常朴素先让模型看用户说什么、判断要不要调用工具、给出结构化回复如果判断需要工具就执行工具函数、把结果喂回模型再让模型继续判断循环一直到模型说“不需要工具了”或者达到最大轮数。# app/agent/loop.py from app.llm.client import LLMClient from app.tools.registry import TOOL_REGISTRY, get_tool_schemas from app.schemas import OrderQueryResult MAX_ITERATIONS 5 def run_agent(llm: LLMClient, user_input: str, context: str ): messages [ {role: system, content: 你是客服助手用代码工具查询信息并回复用户。}, {role: user, content: user_input}, ] for step in range(MAX_ITERATIONS): response llm.complete( messagesmessages, response_formatOrderQueryResult, temperature0.1, ) if not response.need_tool: return response.reply tool_name response.intent # 这里简化为意图直接映射工具名 if tool_name not in TOOL_REGISTRY: return 抱歉我暂时无法处理这个问题。 # 调用工具并准备工具结果消息 tool_result TOOL_REGISTRY[tool_name](response.order_id) messages.append({ role: user, content: f工具 {tool_name} 返回结果{tool_result}。请基于结果回复用户。, }) return 处理超时请稍后再试。这个循环的精髓在于MAX_ITERATIONS 5。模型再强大也不能让它无限地“思考和调用”否则一次异常可能让你烧掉几十次 API 调用。设置最大轮数、设置超时是把不确定性关进笼子的关键动作。实际生产里这个主循环还能加更多控制逻辑记录每步耗时、累计 token 消耗如果某工具连续返回同一结果就强制中断认定为“卡死”加入人工审核回调——模型调用“删除订单”这类危险操作前必须挂起等待管理员确认。这些控制逻辑如果写在拖拽平台里每一个都是灾难写在代码里不过是几个if和一张状态表的事。4. 踩坑实录与排查技巧4.1 上下文窗口失控越聊越慢越聊越贵第一个坑来得特别快。对话历史一长每次请求把所有历史消息都发给大模型一来费 token二来模型处理时间变长三来超出上下文窗口直接报错。解决思路有三层按优先级排序。第一层是策略性截断只保留最近 N 轮对话超过的部分丢弃。大多数客服场景下早于 10 轮前的信息基本没有价值。第二层是摘要压缩把早期对话用一个“历史摘要”消息替代。注意这个“摘要”也是由模型生成的所以本质上是让模型“给自己记笔记”然后再把笔记当上下文。第三层是向量检索把历史对话按语义存入向量库每次请求只取与当前问题最相关的几个片段。准确度最高但实现成本也最大。踩过几次坑之后我的建议是从第一层开始用到不够为止别一上来就上向量库。很多项目根本不至于走到检索那一步。4.2 结构化输出不稳定模型偶尔会“睁眼说瞎话”虽然定义了response_format但极端情况下模型返回的 JSON 还是会解析失败比如字段缺失、引号错乱、值超出了枚举范围。我的处理方式非常保守解析失败时自动重试一次并额外加一条系统提示“请严格按要求的 JSON 格式输出”两次失败则走降级逻辑——直接回复“系统繁忙”。宁可让用户觉得系统笨也不要把模型乱写的数据写进业务库。另一个经验对输出里的枚举值不要直接照单全收。比如intent字段模型把它识别成“查快递”而 schema 里只有“查物流”解析直接报错。这种时候我习惯在 Pydantic 校验前做一层“同义词归一化”把常见别名先映射到标准枚举上再进校验器。这是一个很小的函数但能显著降低校验失败率。4.3 工具调用死循环模型在“反复横跳”最让人头疼的问题是这个模型不停地在几个工具之间换来换去每次都说“我需要再查一下”但状态没有任何推进。本身工具函数执行了几轮钱烧掉了用户还没得到答案。解决办法核心就一句设置轮数上限并在代码层面对“状态无变化”做检测。我在真实实现里会给每轮工具调用记录一个结果摘要如果最新两轮结果完全一样就直接把“你已经查询过这个信息请直接回答用户”追加进消息让模型结束循环。这类逻辑用代码写起来只有十几行但没有人会在拖拽平台里做这种控制。4.4 并发与成本失控每个用户都在偷偷消耗你的 token上线之后另一个坑是并发。常规写法是同步调用单个用户请求没问题一旦并发量上来API 限流和成本双重爆炸。我的办法是两层控制。一层是信号量控制并发数比如同时最多 8 个请求打同一个模型接口另一层是给每次 Agent 跑动设置 token 预算上限计时到或计费到就强制终止。import asyncio semaphore asyncio.Semaphore(8) async def guarded_call(messages): async with semaphore: return await llm.complete_async(messages)成本控制的核心是“事前预算 事后审计”。事前把单次交互的 token 预算写进代码预算耗尽就降级事后把每条日志里的 token 数汇总。4.5 常见问题速查表现象可能原因解决方案模型反复调用同一工具工具结果未真正反馈给模型或结果缺少“确认成功”信号将工具结果完整拼进下一轮消息并检测结果重复JSON 解析偶尔失败模型输出不规范、Schema 描述不清晰重试一次 降级兜底同义词归一化显式提示“严格 JSON 格式”上下文超长报错历史消息无节制增长按最近 N 轮截断上摘要压缩必要时引向量检索成本飙升重试次数过多、上下文过长、循环未设上限设置最大轮数、token 预算、并发信号量日志审计每一步工具调用不准确docstring 描述模糊、参数 Schema 信息不足重写 docstring用示例说明参数格式和取值边界模型输出安全风险用户 prompt 注入、模型输出脱控用户输入进行转义和边界包裹危险工具函数加权限输出做关键词过滤5. 写在最后这套代码可控思路后续还能往哪走我自己的体会是代码可控带来的最大红利不是“性能”或“功能”而是安全感。当你面对一条不断变化的大模型生态时底层模型可以一直换、Prompt 可以天天调、工具函数可以持续加但只要编排逻辑掌握在自己代码里整个系统就是可诊断、可回滚、可演进的。这不是效率问题是工程底线问题。最后分享一个我的判断标准如果你用拖拽平台搭 AI 应用三个月后需要回头想“当初这一步为什么要这么连”那就是失控的信号。如果你的代码里能看到MAX_ITERATIONS、能看到response_format、能看到每一层的单元测试那这个 AI 应用无论模型怎么变你都有底气稳稳接住。按这套思路一个能接真实业务的对话式 AI 应用代码量并不大核心逻辑也就几百行。但同样的需求你把它铺在流程图上可能早就面目全非了。希望这篇文章能帮准备动手 BuildingAI 的你找到那条“灵活、代码可控”的正确路线。