用状态机与执行预算治理智能体的工具调用死循环:一个可运行的 Python 实现

简介: 本文提出智能体生产化中工具调用失控问题的工程化治理方案:通过**状态机(PLAN/ACT/OBSERVE)、执行预算(步数/调用/超时)和工具层治理(参数哈希缓存+重复检测)三层硬约束**,确保工具调用可收敛、可预期、可观测。代码仅依赖标准库,开箱即用。(239字)

一、背景:智能体进生产,最先出问题的是工具调用

把一个基于大模型的智能体(Agent)从 Demo 推到生产环境,团队往往会遇到同一类问题:模型在单轮对话里表现得很聪明,一旦放开工具调用权限,就开始出现各种无法预期的行为——反复查询同一张订单、把一个本可以一步完成的检索拆成七八轮、在多轮失败后仍然不肯收敛,最终把响应时间拖到超时、把调用成本推高数倍。

来看一个典型例子:售后智能体收到用户问题「订单 A1001 到货了吗」,正常的执行路径应该是「调用订单查询工具 -> 拿到状态 -> 生成回答」。但实际运行时,由于模型在上下文中没有正确识别工具已经返回了结果,它会连续三次调用完全相同参数(order_id=A1001)的查询工具,直到上下文被重复的工具结果占满,最终返回一句「抱歉我没查到」。

这类问题的本质,是把一个需要工程约束的系统当成了一次模型推理。模型负责决策,但它没有「步数概念」,没有「这次调用是不是和上次一样」的判断能力,也没有成本意识。因此,治理手段不能只写在 Prompt 里(「请不要重复调用工具」这种指令的可靠性很有限),必须落在代码层的硬约束上。

本文要解决的问题很具体:如何让智能体的工具调用具备可收敛、可预期、可观测的行为。

二、方案设计:三层约束

整体思路是在模型与工具之间加一层执行控制,把「自由的 while 循环」拆成三部分受控逻辑:

约束层 解决的问题 具体手段
状态机 执行流程不透明、无法插入校验点 显式 PLAN → ACT → OBSERVE → PLAN 推进,终态只有 DONE / FAILED
执行预算 步数失控、时间失控、成本失控 最大决策轮数、最大工具调用次数、墙钟超时(可扩展 token 预算)
工具层治理 重复调用、非幂等副作用 工具注册表 + 参数哈希幂等缓存 + 重复命中计数触发拦截

执行时序如下:

         ┌──────────────────────────────┐
         │            PLAN              │  policy(question, history) 产出动作
         └──────────────┬───────────────┘
                        │ action.type == tool
                        ▼
         ┌──────────────────────────────┐
         │            ACT               │  ToolRegistry.call() 前先做
         │  预算检查 / 缓存命中 / 重复检测 │
         └──────────────┬───────────────┘
                        ▼
         ┌──────────────────────────────┐
         │          OBSERVE             │  观测结果写回 history
         └──────────────┬───────────────┘
                        │
                        └────► 回到 PLAN(受 max_steps 约束)

  任意阶段抛出 BudgetExceeded / LoopDetected ──► FAILED(记录 reason)

关键在于:模型只能产出「意图」,能不能真正执行由控制层决定。这样即使换了模型、换了 Prompt,行为边界依然稳定。

三、实现步骤

  1. 定义异常与预算对象:把「超预算」和「打转」定义成两类显式异常,便于上层分别处理(前者通常是扩容或降级问题,后者是 Prompt 或工具设计问题)。
  2. 实现工具注册表:工具以名称注册,携带幂等标记;调用前用「工具名 + 参数规范 JSON」的哈希作为缓存键。
  3. 加入重复检测:同一个缓存键被命中超过阈值,说明模型在原地打转,直接抛 LoopDetected 中断。
  4. 实现执行器:状态机主循环,每轮消耗一次步数预算,每次真实调用工具消耗一次工具预算。
  5. 离线复现:用一个「脚本化 policy」替代真实模型,把正常路径和死循环路径固化成可回归的用例。

四、代码实现

以下代码仅依赖 Python 标准库,可直接保存为 agent_guard.py 运行:

from __future__ import annotations

import hashlib
import json
import time
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Callable


class State(str, Enum):
    PLAN = 'PLAN'
    ACT = 'ACT'
    OBSERVE = 'OBSERVE'
    DONE = 'DONE'
    FAILED = 'FAILED'


class BudgetExceeded(RuntimeError):
    '''步数 / 工具调用次数 / 墙钟时间超预算'''


class LoopDetected(RuntimeError):
    '''相同工具 + 相同参数被反复调用'''


@dataclass
class Budget:
    max_steps: int = 8
    max_tool_calls: int = 6
    deadline_sec: float = 30.0
    steps: int = 0
    tool_calls: int = 0
    started_at: float = field(default_factory=time.time)

    def tick(self) -> None:
        self.steps += 1
        if self.steps > self.max_steps:
            raise BudgetExceeded(f'steps>{self.max_steps}')
        if time.time() - self.started_at > self.deadline_sec:
            raise BudgetExceeded('deadline')

    def spend_tool(self) -> None:
        self.tool_calls += 1
        if self.tool_calls > self.max_tool_calls:
            raise BudgetExceeded(f'tool_calls>{self.max_tool_calls}')


@dataclass
class Tool:
    name: str
    fn: Callable[..., Any]
    idempotent: bool = True


class ToolRegistry:
    def __init__(self, max_cache_hits: int = 2) -> None:
        self._tools: dict[str, Tool] = {
   }
        self._cache: dict[str, Any] = {
   }
        self._hit: dict[str, int] = {
   }
        self.max_cache_hits = max_cache_hits
        self.call_log: list[dict] = []

    def register(self, name: str, fn: Callable[..., Any], idempotent: bool = True) -> None:
        self._tools[name] = Tool(name, fn, idempotent)

    @staticmethod
    def _key(name: str, args: dict) -> str:
        raw = name + '|' + json.dumps(args, sort_keys=True, ensure_ascii=False)
        return hashlib.sha256(raw.encode('utf-8')).hexdigest()[:16]

    def call(self, name: str, args: dict, budget: Budget) -> Any:
        tool = self._tools.get(name)
        if tool is None:
            raise KeyError(f'unregistered tool: {name}')
        key = self._key(name, args)

        # 幂等工具:命中缓存则不再真实执行
        if tool.idempotent and key in self._cache:
            self._hit[key] = self._hit.get(key, 0) + 1
            if self._hit[key] > self.max_cache_hits:
                raise LoopDetected(f'{name}({args}) 重复命中 {self._hit[key]} 次')
            self.call_log.append({
   'name': name, 'args': args, 'cache': 'hit'})
            return self._cache[key]

        budget.spend_tool()
        value = tool.fn(**args)
        if tool.idempotent:
            self._cache[key] = value
        self.call_log.append({
   'name': name, 'args': args, 'cache': 'miss'})
        return value


class AgentRunner:
    def __init__(self, registry: ToolRegistry, budget: Budget | None = None) -> None:
        self.registry = registry
        self.budget = budget or Budget()

    def run(self, policy: Callable[[str, list], dict], question: str) -> dict:
        state, history, trace = State.PLAN, [], []
        action: dict = {
   }
        try:
            while True:
                self.budget.tick()
                if state is State.PLAN:
                    action = policy(question, history)
                    if action.get('type') == 'final':
                        state = State.DONE
                        history.append({
   'role': 'assistant', 'content': action['content']})
                        trace.append({
   'state': state.value, 'content': action['content']})
                        break
                    state = State.ACT
                if state is State.ACT:
                    obs = self.registry.call(action['tool'], action['args'], self.budget)
                    state = State.OBSERVE
                if state is State.OBSERVE:
                    history.append({
   'role': 'tool', 'name': action['tool'], 'content': obs})
                    trace.append({
   'state': 'OBSERVE', 'tool': action['tool'], 'obs': obs})
                    state = State.PLAN
        except (BudgetExceeded, LoopDetected) as exc:
            state = State.FAILED
            trace.append({
   'state': state.value, 'reason': str(exc)})
        return {
   
            'state': state.value,
            'trace': trace,
            'call_log': self.registry.call_log,
            'steps': self.budget.steps,
            'tool_calls': self.budget.tool_calls,
        }


def build_registry() -> ToolRegistry:
    reg = ToolRegistry(max_cache_hits=2)
    db = {
   'A1001': {
   'status': '已签收', 'amount': 199.0},
          'B2002': {
   'status': '运输中', 'amount': 88.0}}
    reg.register('query_order', lambda order_id: db[order_id])
    return reg


def scripted_policy(actions: list[dict]) -> Callable[[str, list], dict]:
    '''把预设动作序列包装成 policy,用于离线复现模型行为'''
    it = iter(actions)

    def _policy(question: str, history: list) -> dict:
        try:
            return next(it)
        except StopIteration:
            return {
   'type': 'final', 'content': '(决策脚本已耗尽)'}

    return _policy


if __name__ == '__main__':
    # 场景一:正常收敛
    good_policy = scripted_policy([
        {
   'type': 'tool', 'tool': 'query_order', 'args': {
   'order_id': 'A1001'}},
        {
   'type': 'final', 'content': '订单 A1001 已签收,实付 199.0 元。'},
    ])
    print(json.dumps(AgentRunner(build_registry()).run(good_policy, 'A1001 到货了吗'),
                     ensure_ascii=False, indent=2))

    # 场景二:模型反复查询同一订单,被缓存与重复检测拦截
    loop_policy = scripted_policy([
        {
   'type': 'tool', 'tool': 'query_order', 'args': {
   'order_id': 'A1001'}}
        for _ in range(5)
    ])
    print(json.dumps(AgentRunner(build_registry()).run(loop_policy, 'A1001 到货了吗'),
                     ensure_ascii=False, indent=2))

关键步骤说明

  • Budget.tick() 在每轮决策前调用:它约束的是「模型思考了几轮」,而不是「工具调用了多少次」。两者分开计数,才能区分是模型话太多还是工具太慢。
  • 缓存键由工具名与规范化参数共同决定:json.dumps(..., sort_keys=True) 保证参数顺序不影响哈希,避免 {'a':1,'b':2} 与 {'b':2,'a':1} 被当成两次不同调用。
  • 缓存命中计数而不是调用计数做重复检测:第一次调用是正常行为,第二次命中缓存通常是为了确认结果,第三次之后基本可以判定为原地打转,此时抛出异常比继续消耗轮次更划算。
  • 异常被收敛为 FAILED 终态:执行器不让异常穿透到调用方,而是回填 reason,上游可以据此决定重试、降级到人工客服或返回兜底话术。
  • call_log 是排查的主要依据:生产中把这份日志连同 traceId 一起写入日志服务,就能快速判断一次失败到底发生在模型决策侧还是工具执行侧。

接入真实模型时,只需把 scripted_policy 换成一个调用模型并解析 tool_calls 的函数,其余控制逻辑完全不用改。工具实现本身可以落在函数计算上,按需伸缩,避免为低频工具常驻资源。

五、注意事项

  1. 幂等缓存只适用于只读或天然幂等的工具。query_order 可以缓存,create_refund 不行。写操作建议改为「业务幂等键 + 结果查询」,而不是直接在框架层缓存返回值。
  2. 预算值要按 SLA 反推。如果首字返回要求 3 秒内,deadline_sec 设为 30 秒没有意义;max_steps 建议从实际流量分布的中位数上浮 2 到 3 倍。
  3. 重复检测阈值不宜过低。用户连续问两次同样的问题,模型查两次订单是合理的,阈值设成 1 会误伤;设为 2 到 3 比较稳妥。
  4. 缓存要考虑一致性与内存增长。生产环境应使用带 TTL 的缓存(如 Redis 兼容服务),并限制 key 数量,否则长会话场景下内存会持续上涨。
  5. 失败要可观测。FAILED 不应静默返回兜底话术,建议按 reason 打点,把 LoopDetected 和 BudgetExceeded 分开告警,前者对应 Prompt 或工具描述问题,后者对应容量问题。
  6. 不要把约束全押在 Prompt 上。提示词可以降低重复调用概率,但无法提供确定性保证,硬约束必须写在代码里。

六、总结

智能体的可控性,很大程度上取决于工具调用这一层是否被当作「基础设施」来设计,而不是当作「模型能力的延伸」来放任。本文给出的三层约束——显式状态机、执行预算、工具层幂等与重复检测——并不复杂,但能把「智能体偶尔抽风」这个模糊的问题,转化为若干个可度量、可告警、可回归的具体指标:步数、工具调用次数、缓存命中次数、失败原因分布。

先用脚本化 policy 把正常路径和死循环路径固化成回归用例,再接入真实模型,是一个成本很低但收益明显的落地顺序。

相关文章
|
云安全 安全 网络安全
|
15天前
|
JSON 程序员 数据格式
最新版Notepad++下载、安装、配置保姆级教程(附官网安装包)
Notepad++ 是一款免费开源、轻量快速的 Windows 文本编辑器,支持多标签、语法高亮、代码折叠与宏录制,体积小、启动快、资源占用低,兼容 x64/x32/ARM 架构,是程序员和文字工作者的高效利器。
3558 0
|
8天前
|
JavaScript 安全 前端开发
Nodejs Brotli decoder 代码执行漏洞 (CVE-2026-111497)10629
Node.js发布安全更新修复Brotli解码器高危RCE漏洞(CVE-2026-111497),建议受影响版本用户立即升级。
2041 2
|
14天前
|
人工智能 API 开发工具
DeepSeek Harness 神插件:白嫖 13 家大厂模型
`dsh-our-free-model` 是 DeepSeek Harness 神级插件:零配置、零API Key、零账号,聚合13家大厂免费模型(含DeepSeek V4.1 Flash、Kimi K3等),支持本地网关、用量看板与自动降级,真正实现“白嫖即用”。
2429 45
|
11天前
|
Web App开发 人工智能 前端开发
2026 年 AI 浏览器怎么选?Ego Lite、Tabbit、Chrome DevTools MCP 深度对比,开发者到底该用哪个?
2026年AI浏览器选型指南:Ego Lite专注多Agent协同(Mac优先),Tabbit国产首选,原生支持Windows与Codex控制;Chrome DevTools MCP非浏览器而是开发者利器,深度集成Console/Network/Performance调试能力,让AI真正“看懂并验证”前端代码。
1467 3
|
8天前
|
开发者 索引
DeepSeekHarness大肥鱼自带插件市场了?加个地址就能用
Open Plugin Market 是一个开放插件索引平台,收录800+优质DSH插件,支持分类浏览、智能推荐与随机发现。无需安装市场,粘贴索引地址即可一键接入,网页版直访 dshopen.org,助力小开发者被看见。(239字)
1897 1
DeepSeekHarness大肥鱼自带插件市场了?加个地址就能用
|
7天前
|
人工智能 Linux API
Magpie, 轻量本地AI网关!免费Token用不完!
Magpie 是开源AI编程模型调度网关,上线一月获1.8万Star。它通过本地高性能网关统一托管多平台API密钥、账号与免费额度,实现跨Agent的模型秒级切换、静默额度轮换与资源复用,显著降低碎片化管理成本。(239字)
Magpie, 轻量本地AI网关!免费Token用不完!
|
8天前
|
安全 应用服务中间件 Go
PHP2Shell 漏洞拆解:WordPress 与 nginx 为何中招
PHP2Shell 双 RCE 已遭在野利用:WordPress、Drupal 全在射程内,nginx 其实另有洞。拆成因与修复顺序
PHP2Shell 漏洞拆解:WordPress 与 nginx 为何中招
|
4天前
|
人工智能 安全 API
2026 年阿里云双 11|百炼大模型 API 指南:免费 Token 领取、API Key 生成、代码调用实战教程
对于零基础的AI开发者、技术爱好者和小型开发团队来说,低成本、低门槛调用主流大模型接口,是开启AI项目原型开发的必经之路。百炼大模型服务平台汇集了百余款主流大模型,面向新用户提供大规模免费推理额度,不需要预先充值,即可完成文本生成、逻辑推理、代码编写、多模态解析等各类开发验证工作。很多新手在初次接触平台时,常常遇到不清楚免费额度规则、不知道如何生成API‑Key、环境配置错误、调用接口持续报错等一系列问题。
583 1

热门文章

最新文章