第零篇:把 Agent 跑起来的最小闭环

从今天开始,我们每天动手拆一块小而关键的能力。第零篇先不谈花哨框架,先把"能跑起来"的最小闭环打通:让一个大模型在受控协议下挑选工具、执行、拿到结果、再决定是继续还是给出最终回答。核心思想很简单,把模型当"策略层",把 Python 函数当"执行层",两者用一条清晰的 I/O 协议粘起来;如果协议清楚,后面换模型、加工具、挂监控都不痛苦。

先把工具准备好。这里不引入笨重依赖,任何一个普通的 Python 函数都能成为工具,函数签名就是调用契约,docstring 顺手当成说明文本。用一个很轻的注册装饰器把它们收进字典里,顺带给模型生成可读的工具清单。为免踩坑,我放了一个真正安全的四则运算器 calc,避免 eval 之类的危险写法,再加一个 now 返回时间,足够演示一个回路。

python 复制代码
from typing import Callable, Dict, Any
import inspect, ast, datetime

TOOLS: Dict[str, Callable[..., Any]] = {}

def tool(fn: Callable[..., Any]) -> Callable[..., Any]:
    TOOLS[fn.__name__] = fn
    return fn

def _safe_eval_expr(expr: str) -> float:
    node = ast.parse(expr, mode='eval')
    def _ev(n):
        if isinstance(n, ast.Expression): return _ev(n.body)
        if isinstance(n, ast.Constant) and isinstance(n.value, (int, float)): return float(n.value)
        if isinstance(n, ast.BinOp):
            l, r = _ev(n.left), _ev(n.right)
            if isinstance(n.op, ast.Add): return l + r
            if isinstance(n.op, ast.Sub): return l - r
            if isinstance(n.op, ast.Mult): return l * r
            if isinstance(n.op, ast.Div): return l / r
            if isinstance(n.op, ast.FloorDiv): return l // r
            if isinstance(n.op, ast.Mod): return l % r
            if isinstance(n.op, ast.Pow): return l ** r
            raise ValueError("unsupported operator")
        if isinstance(n, ast.UnaryOp):
            v = _ev(n.operand)
            if isinstance(n.op, ast.UAdd): return +v
            if isinstance(n.op, ast.USub): return -v
            raise ValueError("unsupported unary op")
        raise ValueError("unsupported expression")
    return float(_ev(node))

@tool
def calc(expression: str) -> float:
    """安全计算四则运算表达式。参数: expression 例 '3*(5+2)'; 返回: float"""
    return _safe_eval_expr(expression)

@tool
def now(tz: str = "local") -> str:
    """返回当前时间的 ISO8601 字符串。参数: tz 可选 'utc' 或 'local'。"""
    if tz.lower() == "utc":
        return datetime.datetime.utcnow().replace(microsecond=0).isoformat() + "Z"
    return datetime.datetime.now().replace(microsecond=0).isoformat()

def tool_specs() -> str:
    lines = []
    for name, fn in TOOLS.items():
        sig = inspect.signature(fn)
        doc = inspect.getdoc(fn) or ""
        lines.append(f"- {name}{sig}: {doc}")
    return "\n".join(lines)

有了工具,还需要让模型"听懂规则、只说 JSON、不要自作聪明"。我更偏向"协议优先"的思路,在 system prompt 里给出硬约束和 JSON 结构,工具清单用人类可读的文本直接注入。为了现实世界的稳定性,再加一层"容错 JSON 解析",兼容模型把 JSON 外面包裹了几句废话的情况。注意这里我不绑定具体厂商 SDK,你可以在自己的工程里把 model_caller(prompt: str) -> str 替换成任意支持"严格 JSON 输出"的调用方式。

python 复制代码
import json, re, textwrap

def build_system_prompt() -> str:
    return textwrap.dedent(f"""
    你是一个严格的执行器。
    只输出 JSON,不要自然语言解释。
    当需要调用工具时,输出:
    {{
      "action": "tool",
      "tool": "<工具名>",
      "args": {{...}}
    }}
    当可以直接回答时,输出:
    {{
      "action": "final",
      "reply": "<给用户的回答>"
    }}
    工具列表(只能二选一:使用其中一个工具,或直接给出最终回答):
    {tool_specs()}
    如果调用工具,参数必须完全匹配函数签名。
    """)

def _extract_json(s: str) -> dict:
    try:
        return json.loads(s)
    except Exception:
        m = re.search(r"\{.*\}", s, re.S)
        if not m:
            raise ValueError("LLM 未返回 JSON")
        return json.loads(m.group(0))

def llm_decide(messages: list[dict], model_caller) -> dict:
    prompt = build_system_prompt() + "\n\n"
    for msg in messages:
        prompt += f"[{msg['role'].upper()}] {msg['content']}\n"
    raw = model_caller(prompt)                    # 这里替换成你的 LLM SDK 调用
    return _extract_json(raw)

最后把回路闭合起来。对话从用户输入开始,模型要么选择一个工具并给出参数,要么直接产出最终回答;如果是工具调用,执行完毕把结构化结果回填给模型,再次决策,直到走到"final"或达到最大轮数停止。为了便于排查,我加了 trace_id、显式日志、以及错误兜底,把工具异常也当作一种可学习的观测传回去,这比直接在代码里吞掉异常更有助于模型纠错。

python 复制代码
import uuid, time, json

def run_agent(user_input: str, model_caller, max_turns: int = 6, trace_id: str | None = None) -> str:
    trace_id = trace_id or str(uuid.uuid4())[:8]
    messages = [{"role": "user", "content": user_input}]
    for turn in range(max_turns):
        decision = llm_decide(messages, model_caller)
        if decision.get("action") == "final":
            reply = (decision.get("reply") or "").strip()
            print(f"[{trace_id}] FINAL: {reply}")
            return reply
        if decision.get("action") != "tool":
            raise ValueError(f"[{trace_id}] 非法 action: {decision}")
        name, args = decision.get("tool"), decision.get("args", {})
        if name not in TOOLS:
            raise ValueError(f"[{trace_id}] 未知工具: {name}")
        print(f"[{trace_id}] CALL tool={name} args={args}")
        try:
            result = TOOLS[name](**args)
            tool_msg = json.dumps({"tool": name, "result": result}, ensure_ascii=False)
        except Exception as e:
            tool_msg = json.dumps({"tool": name, "error": str(e)}, ensure_ascii=False)
        messages.append({"role": "tool", "content": tool_msg})
        time.sleep(0.05)                           # 生产中用退避重试而不是固定睡眠
    raise RuntimeError(f"[{trace_id}] 达到最大轮数仍未完成")

至此,一个可扩展的最小 Agent 就站起来了。它的边界清晰,策略只负责"该不该用哪个工具以及何时收口",执行层只负责"把函数跑对并把结构化结果原样抛回"。这样的拆分有几个直接的工程红利:一是鲁棒性,模型输出不是严格 JSON 时能自修复并留痕;二是可观测性,任何一次工具调用都会形成一条可回放的事件;三是可插拔性,新增能力就是新增一个函数和一条 docstring,不需要改协议。很多人一开始困在"模型说太多、说不准、说错了怎么办",答案不是继续堆提示词,而是把输出缩成机器可执行的最小集合,该说的话等工具跑完再说。

为了让它更像生产系统,建议把三件事尽早补齐。第一件是停止条件和预算控制,在循环里除了最大轮数,再加上 token 预算、总体时延、以及问题级别的降级策略,宁可给出一个"无法完成的清晰回答",也不要无休止地试。第二件是观测打点,把每次工具调用的入参、用时、结果大小、错误类型都打进日志,用 trace_id 串起来,真实线上问题远比示例复杂,没有观测就等于盲飞。第三件是可恢复状态,把 messages 和工具事件串持久化,失败可以复跑,必要时让人工介入继续这一轮,这一点在涉及支付、工单、审批等不可重试操作时尤其关键。

如果你手上已有大模型 SDK,把 model_caller 换成你的一行调用就能跑,比如把 system prompt 放到系统角色,用户输入和工具结果作为后续消息,并且开启"严格 JSON 输出"模式;如果模型不支持严格 JSON,也没关系,本篇的 _extract_json 已经做了宽容解析,但你仍然应该通过提示词去约束输出,最理想的状态是 SDK 自带的 JSON 模式或函数调用机制。把工具再扩充一点也很容易,定义一个接入 HTTP API 的函数即可,例如你可以写一个 @tool def http_get(url: str, timeout_s: int = 10) -> str:,内部用 requests.get 返回文本,然后在 system prompt 的工具清单里它就自然出现了,模型学会读网页或命中的 JSON 就水到渠成。真实世界里的"读写外部系统"类工具非常多,记得保持参数的自描述性,用类型、默认值和 docstring 说清楚约束,模型对"信息密度高且明确的工具说明"更友好。

你可能会问,为什么不直接上现成的多步规划、记忆、反思之类的重型套路。原因很直接,闭环没打通之前谈不上优化,先让"工具---结果---再决策"稳定地转起来,才配得上加上记忆、检索、并发工具、长任务心跳、跨会话状态等高级能力。等这个最小闭环经受住你的真实任务考验,再挂上缓存、重试、指标、链路追踪,它自然会长成一个可维护的 Agent 服务。

今天的范围只做一件事,把最小闭环跑起来;明天换一个点,演示"把任意 HTTP API 一键变成 Agent 工具"的做法,包括如何从 OpenAPI/Swagger 自动生成工具签名、怎样用 JSON Schema 让模型更少猜测,以及如何给可能出错的外部接口加上幂等键与补偿策略。把路修直,后面的车才会跑得快。

相关推荐
说私域3 小时前
开源链动2+1模式AI智能名片S2B2C商城小程序在竞争激烈的中低端面膜服装行业中的应用与策略
大数据·人工智能·小程序
佛喜酱的AI实践3 小时前
Claude Code配置魔法:从单人编程到专属AI团队协作
人工智能·claude
文心快码BaiduComate3 小时前
文心快码Comate3.5S更新,用多智能体协同做个健康管理应用
前端·人工智能·后端
叶楊3 小时前
PEFT适配器加载
人工智能·深度学习·机器学习
我是天龙_绍3 小时前
mybatis-plus 设置 数据库的字段自动填充
后端
Rust菜鸡3 小时前
在你的Rust类型里生成TypeScript的bindings!
后端
Tezign_space3 小时前
AI用户洞察新纪元:atypica.AI如何重塑商业决策逻辑
人工智能·ai智能体·atypica
却道天凉_好个秋3 小时前
OpenCV(十一):色彩空间转换
人工智能·opencv·计算机视觉
间彧3 小时前
Logback、Log4j与SLF4J的区别与选型指南
后端