1. 从一次线上事故说起:为什么"能跑通"和"能上线"是两回事
去年冬天,我帮一个朋友排查他那个"智能客服助手"的问题。Demo 阶段一切正常,用户问天气、问订单、问退换货政策,回答得头头是道。上线第三天,客服主管打电话过来,说系统开始胡言乱语——用户问"我的快递到哪了",它回了一段关于退货政策的说明;用户问"能不能改地址",它开始背诵公司简介。
我打开日志一看,问题很典型:对话轮次一多,上下文里塞满了历史消息,模型开始"抓不住重点"。再往后翻,出现了API error: 400 this model's 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_iterations=8): messages = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user_input} ] for i in range(max_iterations): try: response = call_llm(messages, tools=TOOLS, timeout=30) except ContextLengthError: messages = compress_context(messages) response = call_llm(messages, tools=TOOLS, timeout=30) 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, timeout=10) 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, timeout=10): 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_recent=12): 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,建议先把最小循环跑通,然后立刻加上迭代上限和异常处理,再逐步补上下文管理和评测。不要等到上线出问题了再回头补,那时候成本高得多。