第零篇里,Agent 只能用本地函数干活。今天把边界推到外部世界:给它一个 OpenAPI 片段,就能自动生成可被模型"点名调用"的 HTTP 工具。重点不在花式封装,而是把"可调用契约、参数校验、错误回传"做扎实,保证模型每次出手都在你的防护网里。
先给 Agent 一个朴素但工程可用的 HTTP 执行层。我用统一返回结构把成功与失败都收敛成同一个形态,便于模型学习与我们做观测;同时限制返回大小,防止大响应淹没上下文。生产里你可以接入重试、熔断和指标,这里先把主干打通。
python
import requests, json
from dataclasses import dataclass
from typing import Any
@dataclass
class HttpResult:
ok: bool
status: int
data: Any | None
error: str | None
def http_call(method: str, url: str, *, params=None, headers=None, json_body=None, timeout_s: int = 8) -> HttpResult:
try:
r = requests.request(method.upper(), url, params=params, headers=headers, json=json_body, timeout=timeout_s)
ctype = r.headers.get("content-type", "")
if "application/json" in ctype.lower():
payload = r.json()
else:
payload = r.text[:20000] # 控制返回上限,避免拖垮上下文
return HttpResult(r.ok, r.status_code, payload if r.ok else None, None if r.ok else r.text[:2000])
except Exception as e:
return HttpResult(False, 0, None, str(e))
模型得知道能用哪些工具、如何传参。我们沿用第零篇的思路:函数即工具,docstring 即说明;但今天的函数不是手写,而是由 OpenAPI 语义生成。为了降低门槛,我选了"只解析我们要用的那点信息"的策略:base URL、path、HTTP 方法、path/query 参数与其类型。别被完整规范吓到,能跑通 80% 的公共 API 已经够 Agent 发挥。
python
from typing import Dict, Callable
import re
TOOLS: Dict[str, Callable[..., Any]] = {}
def tool_specs() -> str:
lines = []
for name, fn in TOOLS.items():
lines.append(f"- {name}{fn.__doc__ and ':' + fn.__doc__}")
return "\n".join(lines)
def register_openapi_tool(spec: dict, path: str, method: str, name: str | None = None) -> str:
base = (spec.get("servers") or [{"url": ""}])[0]["url"].rstrip("/")
op = spec["paths"][path][method.lower()]
params = op.get("parameters", [])
summary = op.get("summary", "").strip() or f"{method.upper()} {path}"
tool_name = name or op.get("operationId") or f"{method.lower()}_{path.strip('/').replace('/','_')}"
doc_param = []
for p in params:
t = ((p.get("schema") or {}).get("type") or "any").lower()
need = "必填" if p.get("required") else "可选"
loc = p.get("in")
doc_param.append(f"{p['name']}:{t}({loc},{need})")
doc = f"{summary}。参数:{', '.join(doc_param)}"
def _fill_path(tmpl: str, kv: dict) -> str:
def rep(m):
k = m.group(1)
if k not in kv: raise ValueError(f"缺少路径参数 {k}")
return str(kv.pop(k))
return re.sub(r"\{(\w+)\}", rep, tmpl)
def _typecheck(v, t: str, n: str):
if t == "integer" and not isinstance(v, int): raise TypeError(f"{n} 需要 integer")
if t == "number" and not isinstance(v, (int, float)): raise TypeError(f"{n} 需要 number")
if t == "boolean" and not isinstance(v, bool): raise TypeError(f"{n} 需要 boolean")
if t == "string" and not isinstance(v, str): pass
def created_tool(**kwargs):
path_vars, query_vars = {}, {}
for p in params:
n, loc = p["name"], p.get("in")
need = p.get("required", False)
if need and n not in kwargs: raise ValueError(f"缺少必填参数 {n}")
if n in kwargs:
t = ((p.get("schema") or {}).get("type") or "any").lower()
_typecheck(kwargs[n], t, n)
if loc == "path": path_vars[n] = kwargs[n]
elif loc == "query": query_vars[n] = kwargs[n]
url = base + _fill_path(path, dict(path_vars))
res = http_call(method, url, params=query_vars)
return {"ok": res.ok, "status": res.status, "data": res.data, "error": res.error}
created_tool.__doc__ = doc
created_tool.__name__ = tool_name
TOOLS[tool_name] = created_tool
return tool_name
到这一步,任何符合"有 servers、有 paths、有 parameters"的 OpenAPI 片段都能被注册成一个工具。为了让你能当场试起来,我准备了一个极简规范片段,使用公开天气服务的查询参数语义;真实项目里你会从远端拉取 API 文档,挑你愿意暴露给模型的少数几个端点注册进去。
python
openapi_spec = {
"servers": [{"url": "https://api.open-meteo.com/v1"}],
"paths": {
"/forecast": {
"get": {
"summary": "查询天气预报",
"parameters": [
{"name": "latitude", "in": "query", "required": True, "schema": {"type": "number"}},
{"name": "longitude", "in": "query", "required": True, "schema": {"type": "number"}},
{"name": "hourly", "in": "query", "required": False, "schema": {"type": "string"}}
]
}
}
}
}
tool_name = register_openapi_tool(openapi_spec, "/forecast", "get", name="weather_forecast")
print(tool_specs())
模型看见的"工具清单"此刻已经包含了 weather_forecast
,docstring 里明明白白写着参数、类型和是否必填。第零篇的系统提示里只需把 tool_specs()
串起来,模型就能在需要外部信息时,生成形如 {"action":"tool","tool":"weather_forecast","args":{"latitude":..., "longitude":...}}
的调用请求。要注意的一点是,HTTP 返回常常体积巨大,不要把原样 JSON 生吞进上下文,你应该只保留关键字段或者先做一次下采样,再反馈给模型继续决策。
python
def shrink(payload: Any, budget: int = 4000) -> Any:
try:
s = json.dumps(payload)
if len(s) <= budget: return payload
if isinstance(payload, dict):
keep = {}
for k in list(payload.keys())[:20]:
keep[k] = payload[k]
return {"__truncated__": True, "keys": list(keep.keys()), "sample": keep}
if isinstance(payload, list):
return {"__truncated__": True, "len": len(payload), "head": payload[:20]}
return str(payload)[:budget]
except Exception:
return str(payload)[:budget]
这段压缩器不是"可有可无"的优化,而是把 HTTP→LLM 的信息通道稳定化的关键。很多人把"API 调得越多越好"当成 Agent 的聪明,现实里你需要的是"每一步都把有用的信息以可控大小回传",否则上下文反而会被噪声淹没,模型开始瞎猜。你完全可以在 created_tool
里把 res.data
先喂给 shrink
再返回,这样即使遇到异常大的 JSON,也不会拖垮循环。
在工程边界上再把两件事做硬。第一件是参数层面的自修复,当类型或必填校验失败时,不要静默失败,直接把错误打包成一个结构化的 tool 事件回消息,让模型据此改参重试。这个反馈比"自然语言报错"有效得多,因为它是可机读的、可学习的。
python
def created_tool(**kwargs):
try:
path_vars, query_vars = {}, {}
for p in params:
n, loc = p["name"], p.get("in")
need = p.get("required", False)
if need and n not in kwargs: raise ValueError(f"missing:{n}")
if n in kwargs:
t = ((p.get("schema") or {}).get("type") or "any").lower()
_typecheck(kwargs[n], t, n)
(path_vars if loc=="path" else query_vars)[n] = kwargs[n]
url = base + _fill_path(path, dict(path_vars))
res = http_call(method, url, params=query_vars)
data = shrink(res.data) if res.ok else None
return {"ok": res.ok, "status": res.status, "data": data, "error": res.error}
except Exception as e:
return {"ok": False, "status": 0, "data": None, "error": f"arg_error:{e}"}
第二件是最小化暴露面。不要把整个 OpenAPI 丢给模型,更不要把"写操作"轻易打开。只注册你愿意承担后果的只读端点,并在 docstring 里说清可接受的参数范围。很多线上事故不是模型"变坏",而是我们"给多了"。约束就是能力。
收个尾,把这一小块拼回第零篇的闭环。System Prompt 里还是那条严格 JSON 协议,只是工具清单里多了一个 weather_forecast
。当用户问"明天深圳还下雨吗",模型首先选择工具并给出参数,你执行 HTTP、裁剪响应、回填观测,再让模型根据观测收口或继续。如果今天的这条路走通了,明天你只需再注册两个端点,就能让 Agent 会查汇率、读日历或看库存,业务价值的曲线会非常陡。
下一篇换一条完全不同的路线,我会把"检索当工具而不是把长文硬塞进提示"的做法拿出来,教 Agent 用向量检索与 rerank 做信息定位,用三分钟搭一个不会幻觉的知识问答。