
1. 从一次线上事故说起为什么能跑通和能上线是两回事去年冬天我帮一个朋友排查他那个智能客服助手的问题。Demo 阶段一切正常用户问天气、问订单、问退换货政策回答得头头是道。上线第三天客服主管打电话过来说系统开始胡言乱语——用户问我的快递到哪了它回了一段关于退货政策的说明用户问能不能改地址它开始背诵公司简介。我打开日志一看问题很典型对话轮次一多上下文里塞满了历史消息模型开始抓不住重点。再往后翻出现了API error: 400 this models maximum context length is 1048576 tokens这类报错程序没做任何处理直接把异常抛给了前端用户看到的就是一段莫名其妙的英文错误。这个案例几乎浓缩了所有 AI Agent 初学者会踩的坑把单次调用成功当成了系统可用。一个能跑通的 Agent 和一个可靠的 Agent中间隔着的不是模型能力而是工程能力。这篇内容我想聊的就是这件事——AI Agent 从最小循环到可靠系统中间到底要补哪些东西。关键词里提到的Agent Loop、Function Calling、Prompt、Context正好对应了四个必须搞清楚的层面。不管你是刚准备从 0 到 1 搭建 AI Agent还是已经有一个能跑的 Demo 想往生产环境推这篇应该都能给你一些可以直接抄的作业。我假设读者已经知道大模型 API 怎么调用至少写过一个发消息、收回复的小脚本。如果你连这个都还没做过建议先花半小时跑通一个最简单的对话程序再回来后面的内容会顺很多。2. Agent Loop那个被大多数人低估的最小循环2.1 最小循环到底长什么样很多人第一次接触 Agent脑子里想的是一个会自己思考的智能体。但剥开所有包装Agent 的核心就是一个循环while not done: response llm(messages, tools) if response.has_tool_call: result execute_tool(response.tool_call) messages.append(result) else: done True return response.content就这么几行。Agent Loop 的本质是模型决策 工具执行 结果回灌的反复迭代直到模型认为不需要再调用工具为止。我第一次写这个循环的时候觉得太简单了简单到不像能撑起智能体这么唬人的名字。但后来发现真正难的不是写出这个循环而是让这个循环在异常情况下不失控。2.2 循环的三个致命边界第一个边界是最大迭代次数。模型有可能陷入调用工具→结果不满意→再调用同一个工具的死循环。我见过一个查数据库的 Agent因为 SQL 写错了模型反复重试了 47 次烧掉了几块钱的 token 才被手动掐断。所以循环里必须有一个硬性的max_iterations一般设 5 到 10 就够了超过就强制返回一个兜底回复。第二个边界是工具调用的超时。外部 API 可能卡住数据库可能慢查询。如果工具执行没有超时控制整个 Agent 就挂在那里。我的做法是给每个工具包一层超时比如 10 秒超时后返回一个明确的错误信息给模型让它自己决定是重试还是换方案。第三个边界是循环内的状态污染。这是最隐蔽的。每一轮迭代都会往messages里追加内容如果不做任何清理几轮下来上下文就爆了。这就是关键词里context和maximum context length报错的来源。2.3 一个我实际在用的循环骨架def run_agent(user_input, max_iterations8): messages [ {role: system, content: SYSTEM_PROMPT}, {role: user, content: user_input} ] for i in range(max_iterations): try: response call_llm(messages, toolsTOOLS, timeout30) except ContextLengthError: messages compress_context(messages) response call_llm(messages, toolsTOOLS, timeout30) except Exception as e: return f抱歉处理时出现异常{type(e).__name__} if not response.tool_calls: return response.content for call in response.tool_calls: try: result execute_tool(call.name, call.args, timeout10) except ToolTimeout: result 工具执行超时请尝试其他方式 except Exception as e: result f工具执行失败{str(e)} messages.append({role: tool, content: result}) return 这个问题比较复杂我需要更多信息才能继续处理。这段代码里有几个细节值得说。ContextLengthError单独捕获触发上下文压缩而不是直接失败工具异常被转成字符串回灌给模型让模型有机会自我修正迭代耗尽时返回一个友好的兜底话术而不是抛异常。提示兜底话术不要写系统错误要写我需要更多信息。前者让用户觉得系统坏了后者让用户觉得是沟通问题体验差别很大。3. Function Calling工具设计比工具数量重要得多3.1 工具不是越多越好我见过一个团队给 Agent 接了 30 多个工具从查天气到发邮件到改数据库应有尽有。结果呢模型选错工具的概率高得离谱经常该查订单的时候去调了退款接口。原因很简单Function Calling 的本质是让模型在候选集合里做分类。候选越多分类越难尤其是当工具描述有重叠的时候。我的经验是单个 Agent 的工具数量控制在 5 到 8 个比较舒服超过 10 个就要考虑拆分 Agent 或者做工具路由了。3.2 工具描述是给模型看的 Prompt很多人写工具描述很随意description就写一句查询订单。这等于没写。模型只能靠这个名字猜这个工具干什么、什么时候用、参数怎么填。好的工具描述应该包含三部分这个工具做什么、什么场景下用、参数的含义和格式。举个例子{ name: query_order_status, description: 根据订单号查询订单的当前状态包括物流进度、支付状态、预计送达时间。当用户询问订单进度、快递位置、是否发货时使用此工具。注意此工具只能查询不能修改订单。, parameters: { type: object, properties: { order_id: { type: string, description: 订单号通常是 16 位数字用户可能说订单号、单号或直接给出一串数字 } }, required: [order_id] } }注意最后那句用户可能说订单号、单号或直接给出一串数字——这是在帮模型做参数抽取。实际使用中用户很少规规矩矩地说我的订单号是 xxx更多是帮我看看 1234567890123456 这个到哪了。把这些口语化的表达写进描述里抽取准确率会明显提升。3.3 参数校验不能只靠模型模型生成的参数经常有格式问题。日期可能是明天而不是2026-01-15数字可能是字符串必填参数可能缺失。我的做法是在execute_tool里做一层校验和归一化def execute_tool(name, args, timeout10): schema TOOL_SCHEMAS[name] for param in schema[required]: if param not in args or args[param] in (None, ): return f缺少必要参数{param} args normalize_args(name, args) # 日期解析、类型转换等 return TOOL_FUNCTIONS[name](**args)校验失败时返回的是给模型看的错误信息不是抛异常。模型收到缺少必要参数order_id之后通常会追问用户要订单号这就是我们想要的行为。3.4 工具返回结果也要设计工具返回的内容同样影响模型表现。如果数据库返回一大坨 JSON模型可能抓不住重点。我习惯让工具返回结构化的、精简的结果# 不推荐直接返回原始数据 return {code: 0, data: {...50个字段...}, msg: success} # 推荐返回模型能直接用的信息 return 订单 1234567890123456 当前状态已发货。物流顺丰 SF1234567890预计 1 月 16 日送达。把工具返回也当成 Prompt 的一部分来设计模型的后续决策会稳很多。4. Prompt不是写得越长越好而是边界越清楚越好4.1 System Prompt 的三段式结构我试过很多种 System Prompt 的写法最后稳定下来的是一种三段式结构角色与能力边界、行为规则、输出格式。角色部分要明确这个 Agent 能做什么、不能做什么。比如你是一个电商客服助手可以查询订单、处理退换货咨询但不能修改订单金额、不能承诺赔偿。把不能做的写清楚比只写能做的更重要因为模型在边界模糊时倾向于自作主张。行为规则部分写具体的判断逻辑。比如当用户问题涉及订单时先调用 query_order_status 获取信息再基于返回结果回答不要凭猜测回答。这类规则要具体到可执行不要写要准确回答用户问题这种正确的废话。输出格式部分规定回复的风格和结构。比如回复控制在 100 字以内涉及金额时用人民币符号不要使用 Markdown 表格。4.2 那些让 Prompt 失效的坑关键词里有个invalid prompt: your prompt was flagged as potentially violating our usage policy这个报错我遇到过几次。原因通常是 Prompt 里包含了某些被平台判定为敏感的词汇组合或者用户输入被直接拼进了 System Prompt。永远不要把用户输入拼进 System Prompt。用户输入应该放在user角色的消息里System Prompt 保持固定。这不仅是安全问题也是稳定性问题——用户输入里的特殊字符可能破坏 Prompt 结构。另一个坑是 Prompt 里的指令冲突。比如前面写回答要简洁后面又写要详细解释每一步模型就会摇摆。写完 Prompt 后自己通读一遍看看有没有互相矛盾的指令。4.3 Prompt 版本管理Prompt 是要迭代的而且迭代频率可能比代码还高。我建议把 Prompt 从代码里抽出来单独放在配置文件或者数据库里每次修改记录版本号和修改原因。PROMPTS { customer_service_v3: { content: ..., updated_at: 2026-01-10, note: 增加了退换货政策的判断逻辑 } }这样出问题的时候可以快速回滚也能对比不同版本的效果。我吃过亏——有一次改 Prompt 改出了回归问题但因为没有版本记录花了两个小时才找到是哪次修改引入的。5. Context被最多人忽视、也最容易出事的地方5.1 Context 不是越多越好大模型的上下文窗口越来越大从 4K 到 128K 再到百万级。但这不意味着你应该把所有历史消息都塞进去。上下文越长模型越容易迷失在中间——开头和结尾的信息记得住中间的信息容易被忽略。我的经验是对于客服类 Agent保留最近 10 轮对话 一个滚动摘要就够了。摘要由模型定期生成把更早的对话压缩成几句话。这样既保留了关键信息又控制了上下文长度。5.2 上下文压缩的两种策略滑动窗口最简单只保留最近 N 条消息。优点是实现简单缺点是会丢失早期的重要信息。适合对话主题比较集中的场景。摘要压缩更聪明当消息数量超过阈值时调用模型把早期消息总结成一段话替换掉原始消息。缺点是每次压缩都要额外调用一次模型有成本和延迟。我实际用的是混合策略保留最近 6 轮原始消息更早的消息压缩成摘要摘要控制在 200 字以内。这样既保证了近期对话的细节又保留了长期记忆。def compress_context(messages, keep_recent12): if len(messages) keep_recent: return messages old_messages messages[:-keep_recent] recent_messages messages[-keep_recent:] summary call_llm([ {role: system, content: 把以下对话总结成 200 字以内的摘要保留关键事实和用户诉求。}, {role: user, content: format_messages(old_messages)} ]) return [ {role: system, content: f之前的对话摘要{summary}} ] recent_messages5.3 上下文里该放什么、不该放什么该放的用户的明确诉求、已经确认的事实订单号、用户 ID、当前任务的状态。不该放的工具的原始返回应该精简后再放、模型的中间思考过程除非是 reasoning 模型、重复的寒暄。我见过一个 Agent 把每次工具调用的完整 JSON 都留在上下文里几轮下来上下文就爆了。工具返回应该精简成一句话再回灌原始数据存在外部需要时再查。5.4 上下文长度报错的兜底即使做了压缩也可能遇到maximum context length报错。这时候不能直接失败要有兜底逻辑def call_llm_with_fallback(messages, tools): try: return call_llm(messages, tools) except ContextLengthError: # 激进压缩只保留最近 4 条消息 compressed messages[-4:] try: return call_llm(compressed, tools) except ContextLengthError: # 最后兜底只保留 system 最后一条 user minimal [messages[0], messages[-1]] return call_llm(minimal, tools)三级降级保证任何情况下都能返回一个结果而不是把异常抛给用户。6. 从 Demo 到可靠系统那些必须补上的工程细节6.1 可观测性没有日志的 Agent 等于黑盒Agent 出问题时你需要知道模型收到了什么、返回了什么、调用了哪些工具、工具返回了什么、最终回复是什么。这些都要记日志。我习惯把每次 Agent 运行的完整轨迹存下来包括每轮迭代的 messages、tool_calls、tool_results。出问题时可以完整复现。日志里要注意脱敏用户手机号、地址这些不能明文存。6.2 重试与幂等模型调用可能因为网络问题失败工具调用可能因为外部服务抖动失败。重试是必须的但要注意幂等——查询类工具重试没问题写入类工具重试可能导致重复下单。我的做法是给工具打标签read_only的工具可以自动重试write类的工具不自动重试而是返回错误让模型决定。6.3 限流与成本控制Agent 的 token 消耗可能远超预期尤其是循环多、上下文长的时候。我建议在几个层面做控制单次请求的最大 token 数、单用户的每日调用次数、单次 Agent 运行的最大迭代次数。这些限制要提前设好不要等账单来了才后悔。6.4 评测怎么知道改得好不好Prompt 改了、工具改了、压缩策略改了怎么知道效果是变好还是变差需要一套评测集。我通常准备 50 到 100 条真实用户问题覆盖常见场景和边界情况每次改动后跑一遍对比成功率、平均迭代次数、平均 token 消耗。评测集不用很复杂一个 CSV 文件加一个跑批脚本就够了。关键是坚持跑不要凭感觉判断。7. 几个我踩过的坑和对应的解法7.1 模型假装调用了工具有一次我发现 Agent 回复里说我已经帮您查询了订单但实际上根本没有调用工具是模型编的。原因是 System Prompt 里写了查询订单后告知用户模型直接跳过了查询步骤。解法是在 Prompt 里明确必须先调用工具获取信息再基于工具返回回答禁止在没有工具返回的情况下声称已查询。同时在代码层面校验如果回复里包含已查询但本轮没有工具调用就强制重新生成。7.2 工具参数里的日期解析用户说明天的订单模型可能生成date: 明天这样的参数。工具收到后解析失败。解法是在工具描述里明确日期格式同时在normalize_args里做日期解析把明天后天下周一转成具体日期。7.3 多轮对话里的指代消解用户先说查一下我的订单Agent 问请提供订单号用户回1234567890123456。这时候模型需要知道这个数字是订单号而不是其他东西。解法是在上下文里保留正在等待订单号这个状态可以通过 System Prompt 或者一个显式的状态字段来维护。7.4 循环里的复读机现象模型有时候会连续几轮调用同一个工具、传同样的参数。这通常是工具返回的结果模型不满意但又不知道怎么办。解法是在工具返回里加入引导比如如果此结果不符合预期请尝试用其他参数重新查询或告知用户当前无法获取信息。8. 写在最后可靠是一种设计不是一种运气回到开头那个客服助手的案例。后来我们做的事情其实不复杂加了上下文压缩、加了工具超时、加了异常兜底、把 System Prompt 重写了一遍、准备了一套 60 条的评测集。改动量大概两三天但系统的稳定性完全不一样了。AI Agent 这个领域模型能力在快速进步但工程能力是绕不过去的。Function Calling 再强工具描述写不清楚照样选错上下文窗口再大不做压缩照样爆Prompt 再精妙没有兜底逻辑照样在异常时崩掉。我个人的体会是把 Agent 当成一个分布式系统来设计而不是当成一个 Prompt 来调。循环要有边界工具要有契约上下文要有生命周期异常要有兜底。这些东西听起来不酷但它们是 Demo 和产品之间的那道墙。如果你正在从 0 到 1 搭建 Agent建议先把最小循环跑通然后立刻加上迭代上限和异常处理再逐步补上下文管理和评测。不要等到上线出问题了再回头补那时候成本高得多。