30秒速览
- 别让Agent当全才,专业分工+严格状态机才能避免死锁和库存负数
- CloudWatch trace ID是你调试多智能体黑暗森林的唯一火把,没它根本找不到幽灵调用
- 模型用分级路由省了2000刀月费,但并发控制没做好依然会打爆API限流
让每个Agent只关心自己那一亩三分地,是我做过最正确的决定
刚接手这个电商后台自动化项目时,我第一个想法就是把所有逻辑塞进一个大模型里——一个超级Agent,既能理解订单、又能调支付接口、还能查物流轨迹。听起来很优雅,对吧?我花了整整两天写了一份超长的 instruction,把订单状态机、支付错误码、物流API文档全扔进去,觉得自己像个教父在教导一个全知全能的数字员工。第一次测试我就意识到错了:当订单取消后需要同时退款和拦截物流时,这个Agent一会儿决定先退款,一会儿又觉得应该先拦包裹,反复横跳了四次,最后直接返回了一个“我不知道该先做什么”的诡异回答。那一刻我明白了一个道理:大模型不是人,它处理不来这种多目标、跨领域的决策。
后来我翻Bedrock的文档,看到多智能体协作(Multi-Agent Collaboration)的功能在2025年底已经稳定了。它允许你把多个Agent组成一个群组,每个Agent有自己的知识边界,通过定义好的路由指令互通消息。这正好契合了我当时的想法:让订单Agent只负责订单创建、状态流转和校验;支付Agent只管与Stripe交互、处理预授权和退款;物流Agent只对接快递公司,下发运单、查询轨迹。每个Agent的 instruction 里我会写得很“自私”——比如订单Agent的 instruction 开头就是“你只能处理订单相关的业务,任何支付问题请转给支付Agent,任何物流问题转给物流Agent,不要自己编造支付或物流的结果。” 这种明确的边界在传统的微服务里是天经地义的,但到了AI Agent的世界,大家反而容易忘了——因为模型太能“编”了,它会假装自己查到了支付状态,实际上它只是在幻觉。
通信拓扑上,我一开始想用点对点网状结构,让三个Agent互相都能直接对话,就像三个同事在一个群里讨论。试了一次就放弃了。支付Agent问物流Agent“这单货发了没”的时候,物流Agent会反问“这单支付成功了吗”,然后来回拉扯,token嗖嗖地烧。我最后采用了以订单Agent为中心星的拓扑——所有外部请求先打到订单Agent,它负责协调,需要支付时由它显式调用支付Agent,需要发货时由它调用物流Agent。支付Agent和物流Agent之间永远不直接对话,它们只回答订单Agent的提问。这在Bedrock里通过 agent collaborator 的配置实现,你可以在 associate_agent_collaborator 的 collaborationInstruction 里明确“你只服务于订单Agent,不要与其他Agent直接交流”。这样通信链路极度收敛,调试时我只需要抓订单Agent的调用链,就能看到整笔业务的全局。
关于决策逻辑,我没有让Agent自己去“想”该调用谁,而是在订单Agent的 instruction 和 action schema 里硬编码了决策状态机。什么意思呢?举个例子,订单Agent收到“创建订单”指令后,它必须执行一个叫 create_order 的 action,这个 action 的返回值里会包含“需要支付”的标记,然后订单Agent的 instruction 规定“如果 action 返回 need_payment=true,你必须立即向支付Agent发送消息请求预授权”。这就避免了自由发挥。状态机本身是定义在 DynamoDB 表里的,每个订单一条记录,有 status、version 等字段。Agent每次做决策前,必须先从 DynamoDB 读取当前状态,根据状态机决定下一步可执行的操作。我甚至用条件更新(condition expression)来防止并发覆盖——这个后面会细说。总之,这套架构跑起来的核心就是“专业分工 + 中心协调 + 硬编码状态机”,而不是指望三个Agent围坐在一起商量。
import boto3
import json
# 创建订单Agent的核心action schema片段
agent_client = boto3.client('bedrock-agent')
order_agent_cfg = {
'agentName': 'OrderAgent',
'foundationModel': 'anthropic.claude-3-5-sonnet',
'instruction': '你是订单处理专家。你只能操作订单状态,支付交给支付Agent,物流交给物流Agent。',
'actionGroups': [{
'actionGroupName': 'OrderActions',
'apiSchema': {
'payload': json.dumps({
"openapi": "3.0.0",
"paths": {
"/create_order": {
"post": {
"description": "创建订单,返回need_payment标志",
"responses": {"200": {"description": "成功"}}
}
}
}
})
}
}]
}
支付Agent说已退款,物流Agent却说货在路上了——这个状态不一致差点把库存打穿
Demo 搭建本身其实很快,我用 Bedrock 的控制台把三个 Agent 创建好,配上对应的 Lambda action group,再建一个 Agent Collaboration Group,把三个 Agent 扔进去,就打通了。完整的流程是这样的:用户通过 API Gateway 下单,Lambda 接到请求后调用订单Agent的 invoke_agent,带着自定义的 session_id(就用订单号)。订单Agent解析用户意图,执行 create_order action,在 DynamoDB 里写入一条 status=CREATED 的记录,然后它根据 instruction 发现需要支付,自动向支付Agent发送一个经过 Bedrock 内部路由的消息,内容类似“请为订单 ORD-123 执行100元的预授权”。支付Agent收到后执行 pre_auth action,调用 Stripe,返回成功或失败。订单Agent收到结果后,如果成功就更新状态为 PAID,并且立刻再去调用物流Agent“请为订单 ORD-123 创建运单”。物流Agent再调快递公司接口,返回运单号。整个链路看上去严丝合缝,前几次测试都很完美,直到我开始做异常场景测试。
我模拟了一个网络抖动:支付Agent调用Stripe的超时时间设得比较短,有一次Stripe实际上扣款成功了,但响应延迟了1秒导致支付Agent以为超时失败,于是它返回“支付失败”,订单Agent马上把订单状态改为 CANCELLED,并启动退款流程(再一次调用支付Agent做退款)。问题来了——物流Agent在前一轮已经收到了“创建运单”的请求,并且正在执行。订单Agent取消订单时,物流Agent的运单创建还没有完成(快递公司接口也是异步的),所以物流Agent并不知道订单已取消,继续执行,最后成功获取了运单号,并且把订单状态又改回了 SHIPPED。结果就是:订单在DynamoDB里的状态是 SHIPPED,但支付那边已经退款了,库存也被锁着。我第一次看到这个状态时整个人都懵了,如果不是因为测试环境库存是虚拟的,我们真能把实物库存卖成负数。
这个坑的本质是“状态不一致”——AI Agent 在执行长事务时没有原生的 Saga 机制。传统微服务用 Saga 编排或者两阶段提交来解决,但 Bedrock 多智能体之间的交互是异步、非阻塞的,Agent A 告诉 Agent B 做一件事,它不会一直 hold 住等待,而是继续往下走,这就导致了回滚的时机难以控制。我的解决方案是在订单Agent里嵌入了一个简单的 Saga 逻辑:订单Agent在调用物流Agent之前,先在DynamoDB里把订单状态置为 PAYMENT_CONFIRMED,并且记录一个补偿任务的 JSON,比如“如果后续发现订单取消,则需要执行取消运单操作”。当订单Agent接到支付失败需要取消时,它不直接去改状态,而是先查当前状态,如果发现已经是 PAYMENT_CONFIRMED 甚至 SHIPPED,就执行补偿:对物流Agent发送“取消运单”指令,对支付Agent发送“退款”指令。这需要一个可靠的“发后即忘”的消息传递,我用SQS来缓冲Agent之间的任务,每个Agent轮询自己的队列,这样可以避免消息丢失。同时 DynamoDB 的状态更新必须用乐观锁,version 字段递增,条件表达式确保没有并发覆盖。那段代码我贴在下面,它实际上救了我好几次命。
table = boto3.resource('dynamodb').Table('OrderState')
def transition(order_id, from_status, to_status):
resp = table.update_item(
Key={'order_id': order_id},
UpdateExpression='SET #s = :to, version = version + :inc',
ConditionExpression='#s = :from AND version = :expected',
ExpressionAttributeNames={'#s': 'status'},
ExpressionAttributeValues={
':to': to_status,
':from': from_status,
':inc': 1,
':expected': current_version
},
ReturnValues='UPDATED_NEW'
)
return resp['Attributes']
还有一个让我头疼的问题是:什么时候该拉人工介入。全自动当然好,但有些场景真的不能自动。比如支付Agent连续三次预授权都失败,而且错误码是“风险拒绝”而不是余额不足,这时候就不应该自动重试或取消,而是应该挂起订单,发消息到企业微信让人工审核。我定义了一个阈值规则:凡是支付失败且失败代码在 RISK_BLOCK_CODES 列表里,或者物流Agent返回“地址无效”超过两次,订单Agent必须把状态置为 NEEDS_REVIEW,并停止后续任何自动操作,同时通过 SNS 发通知。这个规则是硬编码在订单Agent的 instruction 里面的,没让模型去自由判断——不是我信不过 Claude,而是我需要确定性的行为,万一它一次聪明一次糊涂,半夜三点把我叫起来处理假阳性,我会疯的。
没有调用链追踪,多Agent就是个薛定谔的盒子——我靠CloudWatch揪出了那个幽灵调用
刚上线那会儿,系统在低流量下还算正常,但有一天凌晨 2 点,运营突然打来电话说有一批订单状态变成 SHIPPED 了,可支付记录全是退款。我爬起来查日志,Bedrock 控制台里的 Agent 对话历史有限,而且三个 Agent 的日志分散在不同的 Log Group 里,根本拼不出一个完整的调用链。我对着混乱的日志看到凌晨五点,终于发现了一个“幽灵调用”:物流Agent在没有订单Agent触发的情况下自己发出了一个创建运单的请求,而那个订单号是之前取消过的。这就像家里明明没人,灯却自己开了。
第二天我立刻着手给系统加 trace。Bedrock Agent 本身会输出 invocation 的事件流,但默认是分散的。我利用了 Lambda 的上下文,在每次 invoke_agent 调用时,我自己生成一个全局的 trace_id(UUID),并通过 inputText 或者 session attributes 传给 Agent。每个 Agent 调用的 action Lambda 都会把这个 trace_id 打出来,同时写入结构化的 JSON 日志到 CloudWatch。然后我用 CloudWatch Logs Insights 写了一条查询:
fields @timestamp, agent, action, trace_id, message
| filter trace_id = "trace-abc123"
| sort @timestamp asc
| limit 50
这条查询一下子就把一笔订单从生到死的所有Agent动作串起来了。顺着这条链我才发现那个“幽灵调用”的真相:原来是我之前配了一个 CloudWatch Events 规则,每小时去扫描 DynamoDB 里处于 PAYMENT_CONFIRMED 超过 1 小时的订单,本来想触发一个催付提醒,结果规则写岔了,直接把订单号发给了物流Agent的入口,导致物流Agent以为收到了发货命令。这个教训太深刻了——在多 Agent 系统里,任何自动化触发都必须携带清晰的来源标识,而且必须经过订单Agent的路由,不能绕过协调者直接调用子 Agent。后来我把所有自动化都收敛到同一个入口 Lambda,它先调用订单Agent,订单Agent再决定下一步,彻底切断了幽灵链路。
可观测性做好了之后,我甚至可以在 Grafana 上做一个大盘,实时看到三类 Agent 的调用量、成功失败率以及当前处于 NEEDS_REVIEW 状态的订单数。每当半夜报警响了,我先打开 Grafana 看一眼,再去 CloudWatch Insights 输入这条报警订单的 trace_id,不用东翻西找。说实话,没有这一步,多 Agent 就是生产环境里的定时炸弹。很多人觉得 Agent 可观测性不重要,那是因为他们只在自己电脑上跑过 Demo,真上了生产,一个幽灵调用就能让你怀疑自己是不是被 AI 霸凌了。
别让Agent开会吃掉你的利润,我用三个模型做分级路由,月省2000美金
项目上线头一个月,我看账单时差点把咖啡喷到屏幕上。光 Bedrock 模型推理费用就超过了 3000 美元,而且大部分都花在了 Agent 之间的来回对话上。一笔正常订单走下来,订单Agent要和支付Agent交互两次、和物流Agent交互一次,每次交互都是好几轮对话,每个 Agent 我都用了 Claude 3.5 Sonnet——这个模型能力很强,但单价也不便宜。更亏的是,支付Agent 90% 的对话都是在解析固定的 Stripe 返回 JSON,然后生成一句“支付成功”或者“支付失败”,这根本不需要 Sonnet 级别的推理能力。
我开始动刀做成本优化。第一步是模型分级。我把支付Agent和物流Agent里那些纯数据解析、无需复杂推理的 action,全切换成了 Claude 3 Haiku(Bedrock 里叫 anthropic.claude-3-haiku)。这个模型便宜很多,而且对于结构化 JSON 的生成完全够用。只有订单Agent需要理解用户多样化的下单意图、处理异常时做推理,才保留 Claude 3.5 Sonnet。我还做了一层路由:在 Lambda 里预先判断任务的复杂度,如果只是常规的状态流转(比如查询订单状态),直接用一个轻量级的 Llama 3.1 8B 模型(通过 Bedrock 的 Meta 模型)生成固定格式的回复,根本不叫 Sonnet。这一下模型费用直接砍掉了 60% 左右。
第二步是并发控制。Bedrock 的每个基础模型都有每分钟调用次数的限制(RPM)和每分钟 token 数限制(TPM),Agent 之间互相调用时如果发生死锁或者无限重试,很容易把限流打爆,其他请求就被拒绝了。我用 SQS 队列作为 Agent 间调用的缓冲区,每个 Agent 都有一个独立的队列。订单Agent想调用支付Agent时,不是直接同步 invoke,而是往支付Agent的队列里发一条消息,然后通过 Step Functions 等待回调。支付Agent的 Lambda 从队列里取消息,处理完后把结果发到订单Agent的回调队列。这样我就能在队列层面控制消费速率——支付Agent的 Lambda 预留并发度设置为 5,SQS 触发 Lambda 的批次大小设为 1,避免了支付Agent突然涌入大量请求把 Stripe 的 API 也打爆。当然,这也增加了延迟,但对于电商后台的非实时场景完全可以接受。
另外一个小技巧是缩短 Agent 的上下文。Agent 间的对话如果带上完整的历史,token 消耗是指数级的。我在 instruction 里明确规定“每次回答问题只基于当前请求,不要引用之前的对话”,并且每次调用前我手动裁剪 input,只带着本次任务的关键信息和上一次任务的结果。这需要你对自己的业务状态机足够自信——因为 Agent 不再“记得”刚才说过什么,全靠 DynamoDB 的状态做主。刚开始我不放心,做了 AB 测试,发现裁剪上下文后成功率和信息完整度几乎没有下降,因为状态已经在数据库里了,Agent 只需要根据状态做出反应,根本不需要回忆。最后账单从 3000 多刀降到了 900 刀左右,对于一个月处理十几万订单的系统来说,这个成本已经相当能接受了。