从今天开始,我们每天动手拆一块小而关键的能力。第零篇先不谈花哨框架,先把"能跑起来"的最小闭环打通:让一个大模型在受控协议下挑选工具、执行、拿到结果、再决定是继续还是给出最终回答。核心思想很简单,把模型当"策略层",把 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 让模型更少猜测,以及如何给可能出错的外部接口加上幂等键与补偿策略。把路修直,后面的车才会跑得快。