第零篇:把 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 让模型更少猜测,以及如何给可能出错的外部接口加上幂等键与补偿策略。把路修直,后面的车才会跑得快。

相关推荐
林小帅1 小时前
【笔记】OpenClaw 架构浅析
前端·agent
林小帅1 小时前
【笔记】OpenClaw 生态系统的多语言实现对比分析
前端·agent
点光2 小时前
使用Sentinel作为Spring Boot应用限流组件
后端
不要秃头啊3 小时前
别再谈提效了:AI 时代的开发范式本质变了
前端·后端·程序员
AngelPP3 小时前
OpenClaw 架构深度解析:如何把 AI 助手搬到你的个人设备上
人工智能
宅小年3 小时前
Claude Code 换成了Kimi K2.5后,我再也回不去了
人工智能·ai编程·claude
有志3 小时前
Java 项目添加慢 SQL 查询工具实践
后端
九狼3 小时前
Flutter URL Scheme 跨平台跳转
人工智能·flutter·github
ZFSS4 小时前
Kimi Chat Completion API 申请及使用
前端·人工智能
山佳的山4 小时前
KingbaseES 共享锁(SHARE)与排他锁(EXCLUSIVE)详解及测试复现
后端