Skip to content

第 2 章 智能体回路 ​

现实里的任务很少是一次调用能做完的:“帮我把上周买的包裹改送到公司”这一句话背后,是查订单、看状态、核对地址、修改信息和回复确认好几件事,模型既拿不到订单系统的数据,也改不了任何字段。本章把一次性的模型调用变成能一步步把任务往前推进的系统,核心结构业界叫智能体回路(Agentic Loop):模型决定要采取行动,代码去执行工具,执行结果再喂回模型,模型据此决定下一步。2.1 讲聊天机器人、Copilot、智能体三档产品的分界,2.2 解剖回路的三个组件和四种能力,2.3 不依赖任何框架手写一个能跑的循环,2.4 列出这套循环离生产环境还差哪些,全书后半部分就照这份清单展开。

2.1 聊天机器人、Copilot 与智能体 ​

同一个需求交给三种产品,得到的回应完全不同。就拿网购里最常见的这句来说:“帮我把上周买的包裹改送到公司”。

聊天机器人能做的是回一段操作指南,告诉用户去哪个页面、点什么按钮、填哪张表单。它有语言能力,没有行动能力。这类系统在国内电商和银行 App 里已经铺开很多年,回答的大多是常见问题。用户问“怎么改地址”,它答得头头是道,真去改地址的还是用户自己。

聊天机器人还有一个更根本的限制:它把每轮对话当成孤立事件。上一句刚说过订单号,下一句再问地址,模型接不接得上,全看工程上有没有把历史消息一起传进去,传多少、怎么裁剪,都是外围代码的事。对厂商来说这不算问题,产品本来就是按一问一答设计的。但对想把这类产品用到新场景的团队来说,限制就在这里。

想让它记住上下文、查到实时数据、动手改系统状态,每一个都得在外面补代码。补到最后,就补出了下一档产品。

Copilot 比聊天机器人多走了一步,直接嵌进人的工作流程里。Copilot 原本指飞机上的副驾驶,2021 年 6 月 GitHub Copilot 推出技术预览版之后,这个词在软件业有了新含义,专指在操作者身边持续提供协助的助手。写代码时,人在编辑器里敲下函数名和注释,Copilot 顺势把整段实现补出来,采不采纳,人按一个键就能定。查资料、调接口它也能做,但每一步谁发起、谁过目、谁拍板,决定权始终在人手里。

这套用法换到写代码以外的场景同样成立。写周报时列出三个要点,它负责起草整段正文;回客诉邮件时把对方的原话贴上去,回复草稿立刻就有了。衡量这类功能,采纳率自然排在第一位,因为最后按下接受键的始终是人。

到了智能体这一档,人只交代目标和边界,比如“把这个包裹改送到公司,收件人手机号别动”。接下来查哪个订单、先核对什么、调哪个接口、结果是否可信、还要不要继续,都由系统自己一步步决定。人从操作者变成验收者,中间过程不用再逐步过目。

智能体这个词也不是这几年才有的。几十年前,学术界就分成过两派:慎思式一派主张先把世界推演清楚再动手,反应式一派主张感知到什么就立刻应对,不做长篇推理。两派各自碰过壁,后来合成了混合架构,上层慢慢规划,下层快速反应。今天用大模型搭的智能体,大体上还是这个格局:模型负责想,工具调用负责做。第 10 章讲的编排模式,决定的就是想和做怎么分工。

三档放在一起比,分界线只有一条:谁决定下一步。人问一句,聊天机器人答一次。Copilot 每次出手也跟在人的动作后面,模型是在人操作的间隙里发挥作用。智能体的中间步骤则由模型自己触发,只把首尾两步留给人:一头给目标,一头收结果。

Anthropic 给过一个更贴近机制本质的定义:“Agents are systems where LLMs dynamically direct their own processes and tool usage, maintaining control over how they accomplish tasks.” 翻成中文就是:在智能体系统里,模型自己动态决定流程怎么走、工具怎么用,任务怎么完成始终由它掌控。

有些资料把自主度画成一条由低到高的序列:单次模型调用、链式调用、条件路由,再到带循环的工具调用,最高一级是运行时自己写代码、造新工具。

图 2-1 自主度谱系:五级左低右高,前三级开发期写死,后两级运行时再定

序列靠前的几级,下一步走哪条路在开发期就写死了:链式调用把上一步的输出接给下一步,条件路由按条件选分支。到后面几级,选择权交给了模型,运行时才决定。聊天机器人、Copilot、智能体这三档,就是这条序列的粗略分组。

Anthropic 那篇文章还顺带定义了工作流,原文是“Workflows are systems where LLMs and tools are orchestrated through predefined code paths.” 意思是,模型和工具沿着预先写好的代码路径编排起来,就是工作流。它和智能体的分界,同样看下一步由谁决定:工作流的每条路径在开发期就定死了,智能体的路径留到运行时再定。

还是用改地址这句话来说。工作流把查单、核对状态、改地址排成一条固定管线,就算查完发现包裹已经派送,管线照样会走到改址这一步。换成智能体,碰到同样的情况会当场放弃改址,把现状告诉用户,再商量怎么办。本书第 9、10 章要讲的架构升级阶梯和编排模式,就在这两个极端之间。

形态每一步由谁发起动作由谁执行出错的代价
聊天机器人人人答案有误,用户自行甄别
Copilot人机器产出,人采纳后生效产出不合格,人工删除重来
智能体模型(首末两步除外)机器直接执行动作已执行,需要回滚或补救

实际产品里,三档的界限往往没有这么清楚。聊天机器人可以在回复里放按钮,点一下直达改地址页面;Copilot 也能上网查资料、装插件;智能体的关键步骤同样可以加一道人工确认。判断属于哪一档,要看连续多步任务里决定权在谁手上,而不是看它有没有某个单独的功能。同一款产品按任务分在不同档位也很常见:客服问答交给聊天机器人,退款走带审批的智能体,电商 App 里往往就是这么搭配的。

这条判据还有个现实用处:识别营销话术。市面上不少产品挂着智能体的名头,其实就是聊天机器人加了几个按钮,或者是早就有的自动化脚本换了个包装。有分析机构按决定权这条线把市面产品过了一遍,真正称得上智能体的只占一小部分(口径见本章参考资料)。选型时不要看产品页怎么称呼,拿一个多步任务丢过去,看中间步骤谁说了算,几分钟就能验出来。

按决定权分档,管的只是中间步骤由谁说了算。任务由谁发起是另一个维度,选型时很容易漏掉。前面三档都得由用户先开口,人不动,系统就没活干。

也有反过来的,叫主动式智能体(proactive agent):它自己盯着环境,事件一到就开始工作。日历助手发现下周有两场安排撞在同一时段,会在用户察觉之前先发消息,建议挪动其中一场;监控智能体看到数据流异常,会自行开始排查。这类智能体把“等人问”换成了“等事件”,连发起权也交了出去。同一个产品可以两头都占:客服智能体回答用户提问时是被动的,发现用量异常主动推送建议时是主动的。

主动是有代价的,代价就出在触发的设计上。环境里的事件大多是噪音,每条都让模型推理一遍,成本扛不住;噪音看多了,模型对真信号的判断也会变差。常见的做法是分级:先用便宜的过滤器挡一轮,规则、关键词和统计阈值都行;过滤器拦不下来的,再交给模型判断值不值得出手。昂贵的推理只花在真正的触发上,成本才负担得起。

python
def should_wake(event: dict) -> bool:
    """决定一个监控事件要不要唤醒智能体,便宜的规则先挡一轮。"""
    if event["type"] == "status_change":        # 例行状态变化,不值得醒
        return False
    if event["type"] == "usage_spike" and event["ratio"] < 3:
        return False                             # 统计阈值:涨幅不到三倍先观察

    # client 是调模型的入口,初始化 2.3 给出,这里先看判断逻辑;
    # 过滤器拦不下来的,才花一次模型调用判断值不值得出手
    resp = client.chat.completions.create(
        model="deepseek-v4-flash",
        messages=[
            {"role": "system", "content": "判断这个监控事件是否需要智能体介入,只回答“要”或“不要”。"},
            {"role": "user", "content": f"监控事件:{event}"},
        ],
    )
    # 注意不能写 "要" in 回答:“不要”里也含“要”,会判反
    return resp.choices[0].message.content.strip().startswith("要")

两道关的顺序不能调换。如果让模型先判断、规则后过滤,每天成千上万条例行事件都会变成要计费的调用。

触发的灵敏度怎么定,两头都有代价。定得太灵,智能体隔三差五跳出来报告异常,十次里有九次是虚惊,用户头两周还会点开看看,后来干脆划掉,提醒没人理了,这个功能就白做了。定得太钝,仓库真出了异常它一声不吭,等人在月底报表里发现,货已经压了一整个星期。一边磨掉用户的耐心,一边放走真实的损失,没有哪个公式能替业务直接给出平衡点,只能把误报一次造成的打扰和漏报一次造成的赔偿放在一起比,反复调整,找到自己这条业务线能承受的位置。

自主度不是越高越好,选型时这一点特别容易想错,直觉总会跟着演示里的全自动走。同样是出错,Copilot 的错就摆在屏幕上,补出来的代码不对,扫一眼删掉,损失几秒钟。智能体的错直接落到真实世界里:收货地址改错一个字,包裹就寄到了陌生的地方,追回来要走客诉、赔运费,可能还会丢掉这个顾客。一个是屏幕上的错,一个是仓库里的错,差别不只是严重程度,还在于出错之后人来不来得及补救。

错误好不好发现,也直接影响选型。Copilot 的错当场摆在人眼前,采纳之前扫一眼就能拦住。智能体的错发生在中间步骤,人不在场,等察觉时动作早已执行完。看不见的错只能靠事后手段补位,日志、记录核对、回滚都要花钱去搭,错误的代价越高、藏得越深,这套手段就得搭得越厚。

除了这些明面上的错误,还有一类风险更隐蔽:智能体每一步都没做错,只是在尽力满足别人给它定的目标。指标一旦定得窄,它就会在这个窄范围内找出聪明但有害的办法。按平均通话时长考核客服智能体,它会学着在用户话没说完时挂断电话;给物流智能体设省油指标,它发现最省油的办法是不出车,配送全部取消,油耗直接归零。指标上的数字很漂亮,糟糕的事都发生在指标之外。这种错位有时看上去甚至像成功,报表上恰恰看不出真正出问题的地方。外部资料里有现成的说法,叫目标错位(goal misalignment)或奖励作弊(reward hacking),指的都是这类风险。

补救的办法在目标的写法上:不要只写要什么结果,还要交代清楚怎么做。“尽快解决工单”只定了结果,手段的边界还得另外补上,比如回复要有耐心、敏感场景转人工、方案不能违反服务政策。智能体对目标的理解停在字面上,字面没写到的路,它都有可能去走。把不许走的路提前写进去,它为了达成指标想出的办法才不会超出这个范围。

选哪一档,取决于愿意拿出多少风险预算。改地址、退款和删数据这类直接改真实状态的动作,最稳妥的是走人机协同(Human-in-the-Loop),关键一步先由人确认再执行。起草文档、补全代码这类任务,停在 Copilot 这一档就够了。真正适合做成智能体的,是这样的任务:步骤数事先说不清,中间要反复查证,人不太在意过程细节,出了差错又查得出来。

三档产品底层是同一套东西:调用的是同一类模型接口,来回传的是同样的消息,差别只在最外层的编排代码上。档位不是买来的,是靠工程搭出来的,哪一档都可以从最简单的形态起步,再逐步加厚。

真要选,拿三个问题过一遍就清楚了。先数步骤:一句话能说完的单步任务,直接调模型或者写规则代码就行,没必要上循环。再看过程:每一步的产出都要人过目、拍板的,用 Copilot 最合适。最后看错误代价:出了错能立刻发现、及时补救的,才值得把中间过程交给模型。

拿这三个问题回头检验开头那句改地址。用户一个星期也未必改一次,App 里点三下就完成了,这类需求聊天机器人加个跳转链接就够用,硬做成智能体是小题大做。

换个前提再看:如果这是客服团队每天上千张的重复工单,改一次地址还牵连运费重算、时效重估,中途要翻三个系统,把整段流程交给智能体包办,省下的人力才抵得过开发的投入。所以要先定档位,再谈技术实现。档位选错了,后面的工程投入都花在了错误的方向上。

2.2 智能体的解剖 ​

智能体的组件主要有三个:模型、工具和记忆。2.1 节问的是谁决定下一步,答案落到代码里,就是把这三个组件串起来的那条循环。

模型处在决策的位置上。它读入当前的全部上下文,产出的只有两类东西:一类是直接给用户看的文本,另一类是结构化的行动请求。后者对应的机制叫函数调用(Function Calling):模型生成一份写有工具名和参数的请求,外部代码拿去执行,再把结果交还给模型。要注意,整个过程中模型不接触任何真实系统,不查数据库、不发网络请求、不做精确算术,它只做判断:现在该开口还是该行动;要行动的话,选哪个工具、带什么参数。

第 1 章说过,模型的输出是概率性的,这一点在这里同样成立,所以它的判断免不了出错。后面的很多工程手段,针对的正是模型会出错这个事实。

采样参数也会影响模型的判断。像第 1 章介绍的 temperature 这类参数,意味着同一份上下文连发两次,模型两次选中的工具也可能不同。传统工程师早就习惯了同样的输入必然得到同样的输出,写测试用例时也默认这一点成立。到了智能体这里,这个默认前提就不成立了,测试、排错的思路都得跟着改。怎么给带有不确定性的系统定判据,第 12 章会专门讲。

行动靠的是工具。工具本质上就是一段外部代码,可以是订单系统的查询接口,可以是短信网关,也可以小到一个数学计算函数。模型看不到代码本身,摆在它面前的只有名字、用途说明和参数格式。工具负责把模型说出来的意图变成系统里的真实动作,风险也随之变大:话说错了只是说错了,工具调错了,就意味着订单真的改了、短信真的发出去了。工具的定义方式和描述写法放到第 4 章细讲。智能体的能力有多强、运行有多稳,最终取决于工具的质量。

工具的粒度切多粗,也得自己拿主意。把查订单和改地址拆成两个工具,模型查完可以停下来,判断该不该改;合并成一个带动作参数的大工具,一步到位,模型也就失去了中途判断的机会。粒度怎么切,取决于业务愿意把多少判断权交给模型,这一点看工具清单就能看出来。

工具和记忆之间还有一块交叉地带。检索既可以做成智能体手里的一个工具,也可以做成组装上下文的外围机制:前一种由模型决定何时查、查什么,后一种由代码在每轮开始前自动把资料补进去。两种做法各有适用场景,第 6、7 章分别展开。

记忆存的是状态。最基础的一种就是会话里的那份消息列表,模型每一轮读到的也是它,这份列表装在上下文窗口(Context Window,模型一次请求能处理的 token 上限)里。跨会话、跨天的状态,比如用户偏好、历史工单,要放到外置存储里,需要时再检索回来。循环每转一轮,消息列表就长一截,所以记忆天然带着两个代价:一是列表越来越长,越来越接近窗口上限;二是排在中间的信息会稀释,不容易得到模型的注意。

记忆的进阶机制放到第 7、8 章专门讲。这一节只需要看清一点:智能体的全部工作状态,都在这份消息列表上。

这份列表还有个容易忽略的属性:模型对它是只读的。历史消息全由外部代码维护,模型要影响历史,唯一的办法是追加:把自己这一轮的决定写进去,把工具的结果也写进去。什么内容该进列表、什么不该进、什么时候裁剪,都是工程层面的取舍,第 7 章的上下文工程就从这里讲起。

快递助手要办的事,一句话就能概括:帮用户查订单、改收货地址。放到这个场景里,两层记忆各管一块。会话内的列表装着本轮对话中的订单号和新地址,用户改到一半突然问起运费,模型还能接上话,靠的就是它。外置存储管跨会话的部分,常用地址、收货偏好都存在那里,下次用户再提改址,默认项可以直接带出来。没有后者,每场对话都得从零开始,体验明显差一截;没有前者,连一次完整的任务都完成不了。

图 2-2 智能体回路:三个组件的循环与三种退出

图里的虚线框是由外部代码维护的循环,框外的三格画的是循环结束的三条出路。这三个组件是怎么转起来的?模型读一遍当前上下文,发现信息不够,就发起一次工具调用;外部代码执行工具,把结果包成新消息放回列表。模型再读、再判断,要么继续调工具,要么认为目标已经达成,输出一段给用户的文本,循环就此结束。什么时候停,由模型自己决定。进展顺利时,是模型自己决定收尾;不顺利时还有另外两种结束方式:轮数达到上限,或者代码层抛出无法恢复的异常。这三种结束方式分别对应任务完成、强制中止、报错中止。生产系统要把后两种也当作正常路径来设计,2.4 节会展开。

传统软件走哪条路,写代码时就定好了,if-else 分支一条条都写得清清楚楚。智能体不一样,路径是运行时由模型一步步临时选的,同一句话,今天可能走三步,明天可能走五步。调试的对象也随之变了:要查的不只是代码逻辑,还有模型在特定上下文里为什么这样选。这个主题在全书里会反复出现。

除了模型、工具和记忆这三个组件,业界谈智能体时还常用能力的视角,即推理、行动、通信和适应。这四项不是四个可以分开安装的模块,而是同一条回路上四个可以分别观察的角度。推理,是模型把手上的全部上下文整理成下一步要做什么;现在多数模型把这一步藏在工具选择里,不会单独写出来,能看到的只有它选了哪个工具、填了哪些参数。行动,就是工具调用真正执行,意图变成了实际动作。

通信有两个方向。面向用户的一端,看的是消息协议里的角色和内容,第 1 章讲过 system、user 和 assistant 三种角色,循环里还要加上第四种 tool,用来装工具的执行结果,2.3 节的代码里就会用到。面向其他系统的一端,就是请求和响应。智能体收发的东西,最终都会变成消息。

适应,指的是拿到观察结果之后怎么调整:工具报错,就换个参数再试;信息不够,就回头问用户;这条路走不通,就换一条。靠的仍然是记忆里的历史,加上模型重新做一次决策。快递助手就有现成的例子:查单返回“订单不存在”,模型转而向用户核对单号,这就是适应。四种能力的强弱,最后都归结到同一个问题:面对当前的上下文,模型能不能给出更好的下一步判断。

拿快递助手把这些组件对应一遍:模型选 DeepSeek 的 deepseek-v4-flash,速度快、价格低,客服这类场景对推理能力的要求本来就不高;工具备两个,query_express 负责查单,update_address 负责改址,各自包装成带描述的普通函数;记忆分两层,会话之内是消息列表,跨会话要用的用户档案放进外置存储。

把这套组合实际走一遍。用户说“订单 SF20260824001 到哪了?还没派送的话,把收货地址改成上海市杨浦区五角场街道 100 号”,模型读消息列表,发现里面还没有这个订单的状态,就调用 query_express 查这个单号。代码执行完,把“已到杭州转运中心,尚未派送”作为结果送回。模型再读一轮,判断可以改址,就发起 update_address,参数填上用户刚给的新地址。代码执行成功,结果回填进列表。模型第三次读的时候,信息已经够全,输出一段面向用户的确认文本,三步走完整个循环。全程没有人介入中间任何一步,这正是 2.1 节划出的那一档。

组件分清楚之后,排查问题就有了着手点。模型答非所问、该调工具时没调,去看模型这一侧的系统提示和工具描述;工具明明调了却没产生动作,去查执行侧的参数和接口返回;模型忘了用户上一句说过的话,去看记忆侧的消息列表是怎么拼起来的。先确定是哪个组件的问题,再抠细节,比一上来就通读全部代码快得多。

组件之外,还有一件事在解剖图上画不出来:这个智能体处在什么样的环境里。环境的可观测程度不同,下棋的智能体能看到整张棋盘,机场调度面对的信息总是缺一角;环境的规则有的稳定、有的多变,退换货政策一年改一回,库存数字一分钟变好几回。可观测性差、规则又多变的环境里,智能体的做法也得跟着变,不能一口气规划到底,要边走边补信息。补信息的办法只有三条:检索资料,向用户追问,或者用传感器测量现场。第 6、15、16 章会分别讲这三条路。

落到工程上,这就是一张环境清单。哪些状态由智能体自己记录,比如会话进行到哪、任务做到第几步;哪些要读外部系统,比如库存、订单和会员等级;从外部读来的数据里,哪些当静态配置缓存起来,哪些当动态事实每次现查;变化怎么感知,是定时同步还是等事件通知。这张清单理清楚了,第 7 章的上下文和第 8 章的记忆才知道什么该进窗口、什么该落存储。跳过这一步直接写代码的团队,后来翻日志常会发现,智能体是在拿一份早已过期的库存数据做决定。

2.3 手写第一个智能体循环 ​

一个不依赖任何框架的智能体,核心只是一个带退出条件的 while 循环,总共几十行,每一行都能看懂;框架文档里那套庞大的系统,封装的正是它。

依赖没有额外门槛,装官方 SDK 就行:pip install openai,取最新版。全书的示例统一使用 OpenAI SDK 的 Chat Completions 接口,这是有实际考虑的:这套接口在业内几乎成了通用格式,中文厂商大多提供了 OpenAI 兼容端点,以后换厂商只需要改密钥、base_url 和模型名三处。下面这张表列了几家常用的厂商和示例模型,它们都提供 OpenAI 兼容端点;base_url 不要听别人转述,以官方文档当前给出的值为准。

厂商示例模型
DeepSeekdeepseek-v4-flash、deepseek-v4-pro
通义(阿里云百炼)qwen-plus
Kimi(月之暗面)kimi-k3
智谱glm-5.3
豆包(火山方舟)豆包系列,model 填模型名或接入点 ID

OpenAI 官方现在建议新项目改用 Responses API,原话是“While Chat Completions remains supported, Responses is recommended for all new projects”。Chat Completions 官方并没有放弃,还会继续维护,只是不再作为主推的接口。本书第一部分仍然选它,是因为它在中文厂商那边的兼容面最广,用来讲机制最不容易产生歧义;各家 SDK 的情况,放到第 17 章统一梳理。

场景仍然用 2.2 节的快递助手。任务只有一句话:“帮我看看订单 SF20260824001 到哪了;如果还没派送,把收货地址改成上海市杨浦区五角场街道 100 号。”查单、判断、改址和汇报四件事都在这一句里,正好用来检验循环。工具先用两个本地函数代替,数据库就是一个写死的字典,把注意力放在回路本身;真实接口怎么接,放到第 4 章。

第 1 章只出现了 system、user 和 assistant 三种角色,循环里要多一种 tool 角色,用来装工具执行完的结果。assistant 消息里也不只有文本,还会带一个 tool_calls 字段,里面是一份份调用请求,每份都有自己的编号、要调用的工具名,以及用 JSON 字符串写成的参数。工具执行完,结果放进一条 tool 消息,靠编号和请求一一对应,模型才能分清哪份结果对应哪次调用。

这个接口是无状态的,服务端对上一轮的内容没有任何记忆,每一轮都要把整份列表原样再发一次,模型每次看到的都是完整的历史。下面这段代码就是按这几条规则写的。

python
import json
import os

from openai import OpenAI

# DeepSeek 提供 OpenAI 兼容端点,换厂商改这里的密钥、base_url 和下面的 MODEL
client = OpenAI(
    api_key=os.environ["DEEPSEEK_API_KEY"],
    base_url="<DeepSeek 兼容端点,见官方文档>",
)
MODEL = "deepseek-v4-flash"

def query_express(order_id: str) -> str:
    """查询快递订单的当前状态和收货地址。"""
    db = {
        "SF20260824001": {
            "status": "已到杭州转运中心,尚未派送",
            "address": "北京市朝阳区望京街道 88 号",
        },
    }
    order = db.get(order_id)
    if order is None:
        return json.dumps({"error": f"未找到订单 {order_id}"}, ensure_ascii=False)
    return json.dumps({"order_id": order_id, **order}, ensure_ascii=False)

def update_address(order_id: str, new_address: str) -> str:
    """把订单的收货地址改为 new_address。"""
    return json.dumps(
        {"order_id": order_id, "new_address": new_address, "updated": True},
        ensure_ascii=False,
    )

tools = [
    {
        "type": "function",
        "function": {
            "name": "query_express",
            "description": "查询快递订单的当前状态和收货地址,入参是订单号",
            "parameters": {
                "type": "object",
                "properties": {
                    "order_id": {
                        "type": "string",
                        "description": "快递订单号,例如 SF20260824001",
                    },
                },
                "required": ["order_id"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "update_address",
            "description": "修改订单的收货地址。仅当用户明确给出新地址时调用",
            "parameters": {
                "type": "object",
                "properties": {
                    "order_id": {"type": "string"},
                    "new_address": {"type": "string", "description": "完整的新收货地址"},
                },
                "required": ["order_id", "new_address"],
            },
        },
    },
]

tool_map = {
    "query_express": query_express,
    "update_address": update_address,
}

def run_agent(user_input: str, max_turns: int = 8) -> str:
    messages = [
        {
            "role": "system",
            "content": "你是快递平台的客服助手,用中文回答。"
            "需要查单或改地址时先调用工具,拿到结果再答复用户。",
        },
        {"role": "user", "content": user_input},
    ]

    for turn in range(max_turns):
        response = client.chat.completions.create(
            model=MODEL,
            messages=messages,
            tools=tools,
        )
        message = response.choices[0].message
        # 模型这一轮的决定原样进历史,后续轮次才能看到它调过什么
        messages.append(message)

        if not message.tool_calls:
            return message.content  # 模型不再要工具,任务收尾

        for tool_call in message.tool_calls:
            fn = tool_map[tool_call.function.name]
            args = json.loads(tool_call.function.arguments)
            result = fn(**args)
            print(f"[第 {turn + 1} 轮] {tool_call.function.name}({args})")
            messages.append(
                {
                    "role": "tool",
                    "tool_call_id": tool_call.id,
                    "content": result,
                }
            )

    return "已达单次任务的轮数上限,请稍后再试或转人工。"

if __name__ == "__main__":
    reply = run_agent(
        "帮我看看订单 SF20260824001 到哪了;"
        "如果还没派送,把收货地址改成上海市杨浦区五角场街道 100 号。"
    )
    print(reply)

运行这份脚本之前,先在环境变量里设好 DEEPSEEK_API_KEY。控制台会先输出两行工具调用,最后才打印面向用户的答复,内容大致是订单已到杭州转运中心、还没派送,收货地址已经改好。与其盯着代码看,不如逐轮看消息列表是怎么一步步变长的,这样更容易看清循环在做什么。

图 2-3 循环三轮的消息列表增长:每轮追加模型的决定与工具的结果,第三轮 tool_calls 为空,循环退出

最后那句答复把用户原话里的两件事都回应到了:包裹到哪了,地址改没改成。一句话拆成了四步执行,收尾时又合成一句自然的答复。

text
第 1 轮结束后:
  system:你是快递平台的客服助手……
  user:帮我看看订单 SF20260824001 到哪了……
  assistant(带 tool_calls):query_express(order_id="SF20260824001")
  tool(tool_call_id 对应):{"order_id": "SF20260824001", "status": "已到杭州转运中心……"}

第 2 轮结束后:追加了 update_address 的 tool_calls 和对应的 tool 结果

第 3 轮:assistant 直接输出文本,tool_calls 为空,循环退出

整个智能体的状态,就是那个 messages 列表。循环每转一轮,往里追加两种消息:模型的决定和工具的观察,除此之外什么都不存。后面章节要讲的上下文工程、记忆管理、轨迹记录,都是围绕这份列表展开的。模型想一步,代码做一步,列表记一步,这个节奏在第 3 章有个正式的名字,叫 ReAct。

而且这份列表只会越来越长。run_agent 每调用一次就新建一份列表,任务结束就丢掉。真实产品里,列表要挂在会话上:用户接着追问一句“运费谁出”,这句话作为一条新的 user 消息进来,循环再转一轮,模型带着之前的全部进展继续处理。第 8 章要讲的短期记忆,最朴素的形态就是这份列表一直延续下去。

模型这一轮的决定必须原样放进列表,tool 消息紧跟在它后面,接口按编号把两者配成对。如果缺了中间那条 assistant 消息,下一次请求连协议校验都过不了。代码里 messages.append(message) 单独占一行,就是为了保住这条对应关系。

响应对象里有个 finish_reason 字段,标明模型这一轮为什么停下。最常见的取值有两个:stop 表示模型认为话已经说完,tool_calls 表示模型发出了工具调用、在等结果。上面的代码用 message.tool_calls 是否为空来决定分支,效果相同;以后翻各家框架的源码,会反复看到这个字段。

max_turns 在这里不是可有可无的。第 1 章提过,模型的输出是概率性的,它可能在两三个工具之间反复打转,自己还毫无察觉。循环如果没有兜底,就会一直调下去,费用越积越多,直到撞上接口限流。轮数上限是最后一道止损线;生产环境里这条线要讲究得多,2.4 节再细说。

工具抛出的错误,也应该原样交还给模型。生产环境里 query_express 可能超时,也可能返回错误码,上面的代码在调用工具的地方故意没有加异常处理,是给 2.4 节留的铺垫。通常的做法是把异常文本包成一条 tool 消息交回给模型;模型看到“订单号格式不合法”这类信息,多半会去向用户问清楚,进程也不会当场崩溃。

DeepSeek 的文档对这一点说得很直接:模型生成的 arguments 不一定是合法的 JSON,偶尔还会出现 schema 里根本没有的参数。demo 阶段,代码里 json.loads(tool_call.function.arguments) 这一行就够用了;到了生产环境,前后要加一层校验,遇到非法参数就回填给模型重试,总比进程在解析这一步直接崩掉强。

代码里那行 print,每轮把调用的工具名和参数打印出来,就是最原始的轨迹记录。出了问题,回头翻控制台,就能看清模型实际做了什么,往往和事先预想的对不上。生产级的观测只是把这件事做得更规范:结构化、落盘和可检索,底层思路是一样的。2.4 节和第 14 章还会再讲到它。

LangGraph 这类框架和各厂商官方的 Agent SDK,封装的也是这几十行循环,只是连同状态持久化、重试和追踪一起做成了现成的组件。自己先手写一遍,再回头看框架文档,每个概念都能对上刚才代码里的某一行:图的节点流转对应那个循环,状态对象对应消息列表,recursion limit 对应 max_turns。

反过来,没亲手写过就直接上框架,真出了问题只能对着抽象层猜。这就是本章先不用框架、先手写一遍循环的用意。到第 17 章按能力清单给各家框架打分时,判断的依据也来自自己亲手写过的这几行代码。

练习:先把 update_address 的 description 改模糊,看模型会不会连地址都不核实就直接改;接着往 tool_map 里注册一个发短信的工具;最后把 max_turns 压到 1,看输出在哪里断掉。

2.4 demo 与生产的差距 ​

demo 跑得顺,和系统能在生产环境里长期稳定运行,是两回事,一句话就能概括这个差距:“demo 一小时,生产一整年”。能跑一小时的代码和能跑一年的系统,差的不是模型,而是工程;这一节把差距逐条拆开,具体怎么补,多数要等第五部分逐章展开,少数能当场演示的,节末直接给代码。

demo 容易给人错觉,多数时候不是演示的人有意作假,而是它跑在一个过于干净的环境里:网络一直稳定,并发永远是 1,演示用的数据也是反复挑过的。这三条前提没有一条写进代码,录 demo 的时候却条条成立,一到生产环境就一起失效。真实环境里并发上来以后,接口超时是家常便饭;数据里混着脏数据,甚至有人故意构造的恶意内容;用户会怎么输入,事先更是想不到。后面要列的差距看着很长,原因多半都在这三条上:哪一条前提失效了,就会多出一个坑。

这三条前提失效之后,出现的是三类不确定性。输出的不确定:同一句话问两遍,答案不一样,1.1 节已经见识过。行为的不确定:同一个目标,智能体两次走出不同的路径,先查单还是先改址,每次的选择可能不同。环境的不确定:外部系统自己会变,上一秒查到的库存,下一秒可能就没了。传统后端工程也要应对不确定性,靠的是重试加幂等:操作失败就重来,重来的结果和第一次一样。这套假设在智能体这里整体失效,因为输出和行为本身就带随机性,失败后原样重跑,结果未必更好,有时还会把做了一半的事再做一遍。第五部分的可靠性工程,一大半就是在为这三类不确定性重新建立规矩。

具体的坑有五个,先说排在最前面的错误处理。demo 里什么都顺,生产环境里不顺才是常态。不顺的情况很多:模型接口会超时、会触发限流;工具背后的服务会抖动,时不时返回错误;模型偶尔生成不合语法的参数;连返回的 JSON 字段,都可能哪天就变了。每一种失败都要有明确的去处,不能悬在半空。去处按错误类型来定:能重试的,退避一阵再试;能降级的,换一条路继续走;模型自己能纠正的,把报错原文回填给它重新生成;前三种手段都用不上的,才上报给人处理。

快递助手一上线就会遇到这类错误。用户想改收件地址,update_address 要去调背后的地址服务,服务超时了,模型这一轮什么结果都没拿到。代码如果在这里直接抛异常,用户看到的就是一直转圈,然后整个会话崩掉。换成把超时原因作为一条 tool 消息回填给模型,模型至少能接住话头,向用户解释一句“系统繁忙,稍后为您重试”。

错误处理真正难的地方不在 try/except 怎么写,而在错误怎么分类。同样是出错,处理方式可能完全相反:限流了,等几秒再发就行;参数不合法,原样重试多半还是不合法,应该把报错原文回填给模型,让它自己纠正;鉴权过期,重试只会一次次撞同一堵墙,应该立刻上报、换新凭证。后两类如果也按重试处理,故障只会越滚越大。怎么分类、分错了怎么兜底,这套判断写下来比那个几十行的循环还长,第 14 章的失败模式清单会把这些错误逐条过一遍。

每类错误都有了去处还不够,循环本身还要防另一种失控:停不下来。max_turns 只是最粗的一道限制,生产环境需要的是一整套预算:总轮数、总 token、总金额和总时长,哪一项越线就熔断。如果什么预算都不设,陷入死循环的会话不会自己停下来;大促期间几千个会话同时在跑,失控的那一个每转一圈都在按 token 烧钱。预算是最后一道闸,装了用不上最好,不装就等于没有任何拦截。

messages 列表放在进程内存里,进程一重启,列表就清空了。2.3 节的脚本无所谓,但生产环境里发版是家常便饭。设想某次发版重启,三十个改到一半的地址会话全部丢失,用户看到的是客服从头再问一遍订单号。会话状态要写进数据库,长任务要有检查点,能从断点接着跑。进程随时可能退出,任务不能跟着丢。

一旦部署成多实例,还有一条要求:不管哪台机器接管某个会话,都要能继续跑下去。做法是不把状态留在进程内存里,改放到共享存储,水平扩展靠的就是共享存储。这整套机制留到第 14 章展开。

循环每走一轮,都要把完整历史再发给模型一次,费用也再算一遍。会话越长,单轮的价格越高,十轮对话的累计支出远比十次独立调用高。这笔开销在 demo 阶段感觉不到,等规模到了日均上万会话,就成了实实在在的预算压力。应对的办法也是现成的:压缩历史,按任务难度把请求路由到不同价位的模型,用足服务商提供的缓存折扣。第 14 章的成本工程会专门细算。

最后一个坑是观测,赶工时最先砍掉的也常常是它。出了错,第一件事是看清当时到底发生了什么:模型看到了什么、做了什么、为什么这样决定。demo 阶段留一行 print 就够了;生产环境需要一份完整的运行记录,每一轮发出去的消息、每一次工具调用的输入和返回、每一步花的时间和消耗的 token,都要留下来。出了问题,拿会话 ID 就能把整个过程重新看一遍。

这份记录对评测同样重要,第 12 章会讲怎么用它来搭回归测试。反过来,一个不留记录的线上智能体出了事,连查都无从下手。

除了这五个坑,还有一类漂移,根源完全在外部。一年时间里,厂商升级模型版本、调整接口行为和改定价,都是常事,demo 里固定下来的那个版本号,厂商不一定还继续提供。

提示词同样会漂移。它看上去只是一段配置,实际上决定了模型怎么干活,改动任何一个字,整体行为都可能变,又没有编译器帮着把关。想用回归评测兜住提示词的改动,前提是模型版本也固定住,否则分数一变,就分不清是提示词改的还是模型自己变了;版本不升,能力会一天天落后;升了,行为会变成什么样又没有把握。这是个两难的问题,第 12 章会讲怎么处理。

五个坑和漂移之外,安全也是绕不开的。智能体一旦真的握有权限,改错地址还不算最大的麻烦;攻击者往数据里塞指令、劫持工具调用,或者权限划得太粗引发误操作,损失就不只是说错一句话了。权限怎么划、沙箱怎么隔、注入怎么防,第 13 章讲。道理是一样的:能动手的能力,会把错误和攻击一起放大。

除了安全,怎么把系统推上线也有讲究。改地址这类操作会直接影响用户的利益,初期比较稳妥的做法是先收紧权限:重要动作执行前加一道人工确认,跑一段时间、攒下数据,再一类一类地放开。2.1 节讲的人机协同,在生产里就是这样用的。审批环节怎么设计、权限怎么逐步放开,第 15 章专门讲。

差距归差距,也别因此否定 demo 的价值。一个下午写出来的循环,至少验证了两件事:这套机制能转起来,这个产品设想成立。这两件事,上面那份差距清单是替代不了的。清单也有它的用处:列出来的都是工程工作,不需要推翻重来,回路本身不用动,一项一项补上就行。

指望换一个更强的模型就能绕过这些坑,也行不通。模型再强,接口照样会超时,进程照样会重启,成本照样要算。demo 阶段省掉的这些工程工作,上线后出了事,还是得逐项补上。

把这些坑放在一起看,demo 和生产各自要回答的问题不一样。demo 要回答的是回路能不能转起来;生产要回答的是它转了上千万次、碰到几百种异常之后还稳不稳。前一个靠机制就够了,后一个要靠工程来托底。本书也是按这条线来分的:前半部分讲机制,后半部分讲工程。

拿 2.3 那份代码当底稿,五个坑各自要补的位置都不难找:给工具调用那一段包上 try/except,让不同的错误走不同的处理分支;max_turns 不再只是一个整数,而是扩成一整套预算;messages 列表从进程内存挪进存储;usage 记录下来,逐轮累计;原来那行 print 换成结构化日志。单看每一处都不算大工程,合起来就是 demo 到生产还差的那些事。把 demo 当作起点,一个坑一个坑填过去,才能走到生产。

下面把其中最典型的两处补强写成代码。循环骨架沿用 2.3 的版本,只新增两个东西:一道预算闸,以及把工具异常包回 tool 消息的处理。

python
import json

TOKEN_BUDGET = 60_000  # 预算闸:单次任务的总 token 上限

def run_agent_guarded(messages: list) -> str:
    used_tokens = 0
    for _ in range(8):                   # 轮数闸:max_turns,最粗的一道止损
        response = client.chat.completions.create(
            model=MODEL, messages=messages, tools=tools,
        )
        message = response.choices[0].message
        messages.append(message)
        used_tokens += response.usage.total_tokens

        # 任务已完成的现成答复先放行,不该被预算拦下
        if not message.tool_calls:
            return message.content

        if used_tokens > TOKEN_BUDGET:   # 超预算就地收尾,不再发起下一轮
            return "任务超出本次预算,已停止,请转人工。"

        for tool_call in message.tool_calls:
            args = json.loads(tool_call.function.arguments)
            try:
                result = tool_map[tool_call.function.name](**args)
            except TimeoutError:
                result = json.dumps({"error": "工具执行超时"}, ensure_ascii=False)
            messages.append(
                {"role": "tool", "tool_call_id": tool_call.id, "content": result},
            )
    return "已达单次任务的轮数上限,请转人工。"

和 2.3 相比,真正改变行为的只有三处:usage 记录、预算判断和异常回填。工具超时不会再拖垮整个进程,错误文本进入列表之后,模型下一轮读到它,要么向用户解释一句系统繁忙,要么自己换参数再试。其余三个坑,即状态落库、成本核算和结构化轨迹,第五部分会逐条展开。

练习:拿 2.3 节的脚本,让 query_express 每次调用有十分之一的概率抛出超时异常,连续跑二十遍,记下有几遍靠模型自己纠正就完成了、有几遍拖到 max_turns 才收场,顺便想想熔断加在哪一行最合适。

本章小结 ​

  • 2.1 三档分界:聊天机器人、Copilot 和智能体的分界线只有一条,谁决定下一步;选档拿三个问题过一遍,先数步骤,再看过程要不要人逐步过目,最后看错误代价能不能当场发现。
  • 2.2 解剖:组件有三个,模型负责判断、工具负责行动、记忆负责保存状态;推理、行动、通信和适应四项能力是观察同一条回路的四个角度;智能体的全部工作状态都在消息列表上,模型对列表只读。
  • 2.3 手写回路:核心是带退出条件的 while 循环,消息列表是唯一状态,模型的决定和工具的观察逐层叠加;循环有三种结束方式,模型收尾、轮数上限和代码层异常,后两种也要当正常路径设计。
  • 2.4 demo 与生产:差距是错误处理、循环预算、状态持久化、成本核算和轨迹观测五个坑,外加漂移与安全;预算闸和异常回填两处补强当场就能写进代码。

最后留一道设计题:把本章这几十行回路用到业务里一个真实的小任务上,工具至少两个,熔断至少一条。度量办法是:准备十条真实输入,记录任务通过率、平均轮数和单次任务 token 花费三项。交付物是一个能运行的脚本,加一张三列的成绩表。哪个数字不达标,第 7 章和第 12 章正好可以拿来改进。

参考资料 ​

  • DeepSeek API 官方文档(OpenAI 兼容端点、模型名、finish_reason 取值与 arguments 校验提示)https://api-docs.deepseek.com/ 核实于 2026-08
  • Kimi 开放平台 API 文档(OpenAI 兼容调用与模型名,文档域名已迁至 kimi.com,旧域名跳转可达)https://platform.kimi.com/docs/api/chat 核实于 2026-08
  • 阿里云百炼文档:OpenAI Chat 接口兼容 https://help.aliyun.com/zh/model-studio/compatibility-of-openai-with-dashscope 核实于 2026-08,2026-08-30 复核仍一致
  • 智谱开放平台文档:OpenAI API 兼容 https://docs.bigmodel.cn/cn/guide/develop/openai/introduction 核实于 2026-08
  • 火山方舟文档:兼容 OpenAI SDK https://www.volcengine.com/docs/82379/1330626 核实于 2026-08
  • OpenAI 文档:Migrate to the Responses API https://developers.openai.com/api/docs/guides/migrate-to-responses 核实于 2026-08
  • Anthropic:Building Effective Agents https://www.anthropic.com/research/building-effective-agents 核实于 2026-08
  • GitHub Blog:Introducing GitHub Copilot https://github.blog/news-insights/product-news/introducing-github-copilot-ai-pair-programmer/ 核实于 2026-08
  • 《Build an AI Agent (From Scratch)》(Manning MEAP,2026)第 1 章 What is an AI agent?、第 4 章 Implementing a basic ReAct agent,见 txt-ref-zh/build-ai-agent-from-scratch/
  • 《AI Agent 开发实战》第 2 章 LLM 和 Agent(从零构建 Agent 的 while 循环与消息协议),见 txt-ref/ai-agent开发实战/
  • Dhivya Nagasubramanian《Agentic AI for Engineers》(Apress/Springer 2026)第 4 章(主动式智能体:人问换事件触发、触发分级——便宜过滤器先挡贵的模型推理再上、灵敏度两头是警报疲劳与漏报、过度积极的智能体会被无视;环境维度:可观测性与不确定性分级、环境性质决定行为策略、信息不全时检索追问传感三路补全)、第 8 章(窄指标被聪明而破坏性地赢:客服为缩短通话提前挂断、物流为省油取消配送,错位可以长得像成功,目标是写清怎么干;goal misalignment、reward hacking 两个名词即出于此章),见 txt/agentic-ai-for-engineers.txt(英文原文,第 4 章行 4431-4454、5486-5501;第 8 章行 9662-9677、9743-9757)
  • 《Agentic Mesh》(Eric Broda、Davis Broda,O'Reilly 2026)第 1 章(agent-washing:按 LLM 指挥自身流程的定义过筛,市面产品只一小部分真称得上智能体——分析机构口径,具体比例未采),见 txt-ref-zh/agentic-mesh/
  • 《Multimodal, Real-Time AI Agent Systems》(Heiko Hotz、Sokratis Kartakis,O'Reilly 抢先版 2026)前言(慎思式与反应式两派及混合架构的传统智能体学术脉络),见 txt-ref-zh/multimodal-realtime-agents/
  • 《Designing AI Agents》(Jia Huang,Manning MEAP)第 1 章(输出、行为、环境三不确定性框架,及传统重试与幂等假设在智能体面前的整体失效),见 txt/designing-ai-agents.txt(英文原文)
  • 《构建 Agentic AI 系统》(Anjanava Biswas、Wrick Talukdar 著,茹炳晟、殷海英译,清华大学出版社 2025)第 7 章(状态与环境建模:自身状态的表示、世界规则静态与动态之分、事件驱动更新),见 txt/构建AgenticAI系统.txt