agent.py

简介: 本文详解如何从零实现可运行的AI智能体循环,涵盖工具调用、状态管理与失败重试。内容包括背景问题、模块化设计、分步实现,并提供仅依赖Python标准库的完整示例代码——含MockLLM(离线可运行)、安全计算器工具、OpenAI兼容接口及可观测性追踪。强调工程实践要点:格式容错、重复检测、上下文裁剪与安全执行。(238字)

The user wants a technical article about "智能体" (AI Agents). Need to produce JSON with title, summary, tags, markdown. 1500-3000 characters (字). At least one runnable code example. Structure: background/problem, design, implementation steps, code, notes, summary. Tags 3-5, prefer common tech tags like AIGC, Python, etc.

I should write a concrete, runnable Python example of a minimal agent loop with tool calling — but must not fabricate APIs. I can use a self-contained example that doesn't depend on a specific LLM SDK, e.g., implement a ReAct-style loop with a pluggable LLM interface, and provide a mock LLM for testing so it's runnable without API keys. Or use OpenAI-compatible HTTP API via requests — that's stable and real. Better: write a minimal agent framework with tool registry in Python, using an OpenAI-compatible chat completions endpoint via requests (widely supported, e.g. DashScope compatible mode). But to be safe and runnable, provide a MockLLM so the code runs offline, and note how to swap in a real model.

Let me design a good article:

Title: 从零实现一个可运行的智能体循环:工具调用、状态管理与失败重试

Sections:

  1. 背景/问题: LLM 单轮问答的局限; 智能体 = 模型 + 工具 + 循环 + 记忆; 工程上的问题: 循环终止、工具schema、错误处理、幂等、成本控制、可观测性。
  2. 方案设计:
    • 核心循环: 输入 -> 规划 -> 调用工具 -> 观察 -> 直到终止
    • 组件: LLM 客户端抽象、工具注册表(JSON Schema)、执行器(超时/重试/权限)、上下文/记忆裁剪、轨迹记录
    • 状态机图说明
  3. 实现步骤:
    • step1 定义工具注册表 + schema
    • step2 实现 LLM 抽象 (Mock + OpenAI 兼容)
    • step3 实现 agent loop with max_steps, 重复调用检测
    • step4 可观测性 (trace)
  4. 代码: complete runnable Python single file. Use only stdlib. Mock LLM that returns tool calls in a fixed format.
  5. 注意事项: prompt injection, 工具权限最小化, 幂等与副作用, 上下文膨胀, 终止条件, 成本, 评测
  6. 总结

Let me write the code carefully so it actually runs.

Design:

# agent.py
from __future__ import annotations
import json, re, time, uuid
from dataclasses import dataclass, field
from typing import Any, Callable, Protocol

Tool registry:

@dataclass
class Tool:
    name: str
    description: str
    parameters: dict  # JSON Schema
    func: Callable[..., Any]
    timeout_s: float = 5.0

class ToolRegistry:
    def __init__(self):
        self._tools = {
   }
    def register(self, tool): ...
    def schema_text(self) -> str: ...
    def call(self, name, args) -> str  # returns string observation, catches exceptions

Guard: limit args, catch Exception, return error string.

LLM interface:

class LLM(Protocol):
    def chat(self, messages: list[dict]) -> str: ...

Mock LLM: parse messages, if no observation yet -> output tool call for calculator; after observation -> final answer. Simple heuristic.

Action parsing: use a simple format:

Thought / Action block:

<tool_call>{"name": "...", "arguments": {...}}</tool_call>

Or final answer <final>...</final>.

Simpler: define protocol in the system prompt: model must output either JSON with {"type":"tool_call", ...} or {"type":"final", "answer": ...}. Use JSON parsing with fallback.

MockLLM: examines last message; if role == "user" -> plan tool call; if role == "tool" -> produce final answer that includes observation.

Let me write:

class MockLLM:
    """离线可运行的假模型,仅用于验证 Agent 循环,不产生真实语言能力。"""
    def __init__(self):
        self.turn = 0
    def chat(self, messages):
        self.turn += 1
        tool_msgs = [m for m in messages if m["role"] == "tool"]
        if not tool_msgs:
            return json.dumps({
   
                "type": "tool_call",
                "name": "calculator",
                "arguments": {
   "expression": "(128 * 3 + 40) / 4"},
                "thought": "需要先算出人均成本"
            }, ensure_ascii=False)
        last = tool_msgs[-1]["content"]
        return json.dumps({
   
            "type": "final",
            "answer": f"计算结果为 {last},这就是人均成本。"
        }, ensure_ascii=False)

Wait — the calculator tool executes safely. Use ast-based safe eval, not eval. That's better practice and demonstrates a note about安全。

Calculator tool:

import ast, operator
_OPS = {
   ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul, ast.Div: operator.truediv, ast.Pow: operator.pow, ast.Mod: operator.mod, ast.USub: operator.neg, ast.UAdd: operator.pos}

def safe_eval(expr: str) -> float:
    def _eval(node):
        if isinstance(node, ast.Expression): return _eval(node.body)
        if isinstance(node, ast.Constant):
            if isinstance(node.value, (int, float)): return node.value
            raise ValueError("只允许数字常量")
        if isinstance(node, ast.BinOp):
            op = _OPS.get(type(node.op))
            if op is None: raise ValueError("不支持的运算符")
            return op(_eval(node.left), _eval(node.right))
        if isinstance(node, ast.UnaryOp):
            op = _OPS.get(type(node.op))
            ...
        raise ValueError("不支持的表达式")
    return _eval(ast.parse(expr, mode="eval"))

Agent loop:

@dataclass
class AgentResult:
    answer: str
    steps: int
    trace: list[dict] = field(default_factory=list)

class Agent:
    def __init__(self, llm, registry, max_steps=6, max_repeat=2, verbose=True):
        ...
    def run(self, task: str) -> AgentResult:
        messages = [{
   "role":"system","content": SYSTEM_PROMPT + self.registry.schema_text()},
                    {
   "role":"user","content": task}]
        seen = {
   }
        for step in range(1, self.max_steps+1):
            raw = self.llm.chat(messages)
            action = parse_action(raw)   # raises on invalid
            ...

Handle: invalid JSON -> append a "observation" telling model format error and continue (retry). That's a real design point.

Signature for repetition detection: name + json.dumps(args, sort_keys=True).

If repeated > max_repeat -> stop with fallback.

Also record trace with timing.

parse_action:

def parse_action(raw: str) -> dict:
    text = raw.strip()
    # 容忍 ```json fence
    if text.startswith("```"):
        text = re.sub(r"^```[a-zA-Z]*\n?|\n?```$", "", text).strip()
    try:
        data = json.loads(text)
    except json.JSONDecodeError as e:
        raise ActionFormatError(f"输出不是合法 JSON: {e}")
    if not isinstance(data, dict) or data.get("type") not in ("tool_call","final"):
        raise ActionFormatError("缺少 type 字段或取值非法")
    ...

Now the main: build registry with calculator + a "get_weather" mock? One tool is enough, but two shows registry. Keep calculator plus a word_count? Let's keep calculator only but mention registering more. Actually add a second tool now returning fixed date? Might be unnecessary. I'll add two: calculator and unit_lookup? Keep it simple: calculator + read_kv? I'll just do calculator, plus show registering another tool in "扩展" snippet.

Hmm, better to demonstrate registry with two tools so schema listing is meaningful. Add current_time that returns time — that's fine and deterministic-ish. But MockLLM only uses calculator. That's fine.

Also need OpenAI-compatible real LLM adapter:

class OpenAICompatLLM:
    def __init__(self, base_url, api_key, model, timeout=60):
        ...
    def chat(self, messages):
        import urllib.request
        payload = {
   "model": self.model, "messages": messages, "temperature": 0}
        req = urllib.request.Request(self.base_url.rstrip("/") + "/chat/completions",
            data=json.dumps(payload).encode(), headers={
   ...})
        with urllib.request.urlopen(req, timeout=self.timeout) as r:
            data = json.loads(r.read())
        return data["choices"][0]["message"]["content"]

That's real OpenAI-compatible API. Note: uses only stdlib urllib. Good.

Add retry wrapper for LLM call with exponential backoff.

Also context trimming: keep system + last N messages.

Let me keep the code compact but complete, ~120 lines. Word count: article 1500-3000 字. Chinese chars count. Code doesn't count much toward 字 but fine.

Now write markdown.

I need to double check the agent loop message handling: after tool call, append assistant message with raw content, then append tool message:

messages.append({
   "role":"assistant","content":raw})
messages.append({
   "role":"tool","name":name,"content":observation})

Note: OpenAI API requires tool_call_id for role=tool when using native function calling, but here we're using a text protocol, so role "tool" with content is fine for our own prompt format — but if using OpenAICompatLLM, sending role "tool" without tool_call_id may error. Safer: use role "user" with prefix "OBSERVATION:" ... Hmm. Actually many compatible APIs will reject role "tool" without tool_call_id. To be safe, use role "user" content "[OBSERVATION] ..." — that's a common ReAct-style trick and works everywhere. I'll do that and note it in 注意事项.

So MockLLM checks for messages with role "user" and content starting with "[OBSERVATION]".

Let me restructure MockLLM:

def chat(self, messages):
    obs = [m["content"] for m in messages if m["role"] == "user" and m["content"].startswith("[OBSERVATION]")]
    if not obs:
        return json.dumps({
   ...tool_call...})
    return json.dumps({
   "type":"final","answer": f"..."})

Good.

Agent loop handling of format errors: append assistant raw + user message "[OBSERVATION] 上一步输出无法解析:... 请只输出 JSON。" and continue (counts as step).

Also need: first turn messages list. Final answer extraction.

Let me write code fully:

"""mini_agent.py —— 一个只依赖标准库的最小智能体循环"""
from __future__ import annotations

import ast
import json
import operator
import re
import time
import urllib.request
from dataclasses import dataclass, field
from typing import Any, Callable, Protocol

# ---------- 1. 工具层 ----------

class ToolError(Exception):
    pass

@dataclass
class Tool:
    name: str
    description: str
    parameters: dict[str, Any]
    func: Callable[..., Any]

class ToolRegistry:
    def __init__(self) -> None:
        self._tools: dict[str, Tool] = {
   }

    def register(self, tool: Tool) -> None:
        if tool.name in self._tools:
            raise ValueError(f"工具重复注册: {tool.name}")
        self._tools[tool.name] = tool

    def schema_text(self) -> str:
        items = [
            {
   "name": t.name, "description": t.description, "parameters": t.parameters}
            for t in self._tools.values()
        ]
        return json.dumps(items, ensure_ascii=False, indent=2)

    def call(self, name: str, args: dict[str, Any]) -> str:
        tool = self._tools.get(name)
        if tool is None:
            return f"ERROR: 未注册的工具 {name}"
        if not isinstance(args, dict):
            return "ERROR: arguments 必须是 JSON 对象"
        try:
            result = tool.func(**args)
            text = str(result)
            return text[:2000]   # 观察结果截断,避免上下文膨胀
        except TypeError as e:
            return f"ERROR: 参数不匹配 {e}"
        except Exception as e:
            return f"ERROR: {type(e).__name__}: {e}"

safe_eval:

_BIN_OPS = {
   ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
            ast.Div: operator.truediv, ast.Mod: operator.mod, ast.Pow: operator.pow,
            ast.FloorDiv: operator.floordiv}
_UNARY_OPS = {
   ast.USub: operator.neg, ast.UAdd: operator.pos}

def safe_eval(expr: str) -> float:
    """只允许数字与四则运算,不使用 eval。"""
    def _walk(node: ast.AST) -> float:
        if isinstance(node, ast.Expression):
            return _walk(node.body)
        if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
            return node.value
        if isinstance(node, ast.BinOp) and type(node.op) in _BIN_OPS:
            return _BIN_OPS[type(node.op)](_walk(node.left), _walk(node.right))
        if isinstance(node, ast.UnaryOp) and type(node.op) in _UNARY_OPS:
            return _UNARY_OPS[type(node.op)](_walk(node.operand))
        raise ValueError(f"表达式含不允许的语法: {ast.dump(node)[:60]}")
    return float(_walk(ast.parse(expr, mode="eval")))

Then tool defs:

def build_registry() -> ToolRegistry:
    reg = ToolRegistry()
    reg.register(Tool(
        name="calculator",
        description="计算只含数字和 + - * / % ** 的算术表达式,返回数值字符串",
        parameters={
   "type": "object",
                    "properties": {
   "expression": {
   "type": "string", "description": "如 (128*3+40)/4"}},
                    "required": ["expression"]},
        func=lambda expression: safe_eval(expression),
    ))
    reg.register(Tool(
        name="now",
        description="返回当前时间的 ISO8601 字符串,无参数",
        parameters={
   "type": "object", "properties": {
   }, "required": []},
        func=lambda: time.strftime("%Y-%m-%dT%H:%M:%S"),
    ))
    return reg

Wait: time.strftime with lambda no args - fine.

LLM layer:

class LLM(Protocol):
    def chat(self, messages: list[dict[str, str]]) -> str: ...

class OpenAICompatLLM:
    """任意 OpenAI 兼容的 /chat/completions 端点,例如本地 vLLM、Ollama 的兼容口。"""
    def __init__(self, base_url: str, api_key: str, model: str, timeout: float = 60.0):
        self.url = base_url.rstrip("/") + "/chat/completions"
        self.api_key = api_key
        self.model = model
        self.timeout = timeout

    def chat(self, messages):
        payload = {
   "model": self.model, "messages": messages, "temperature": 0.0}
        req = urllib.request.Request(
            self.url,
            data=json.dumps(payload).encode("utf-8"),
            headers={
   "Content-Type": "application/json",
                     "Authorization": f"Bearer {self.api_key}"},
            method="POST",
        )
        with urllib.request.urlopen(req, timeout=self.timeout) as resp:
            data = json.loads(resp.read().decode("utf-8"))
        return data["choices"][0]["message"]["content"] or ""

MockLLM as described.

Parser:

class ActionFormatError(Exception):
    pass

_FENCE = re.compile(r"^```[a-zA-Z]*\s*|\s*```$")

def parse_action(raw: str) -> dict[str, Any]:
    text = _FENCE.sub("", raw.strip())
    try:
        data = json.loads(text)
    except json.JSONDecodeError as exc:
        raise ActionFormatError(f"输出不是合法 JSON({exc})") from exc
    if not isinstance(data, dict):
        raise ActionFormatError("输出必须是 JSON 对象")
    kind = data.get("type")
    if kind == "final":
        if not isinstance(data.get("answer"), str):
            raise ActionFormatError("final 缺少字符串字段 answer")
    elif kind == "tool_call":
        if not isinstance(data.get("name"), str) or not isinstance(data.get("arguments", {
   }), dict):
            raise ActionFormatError("tool_call 需要字符串 name 与对象 arguments")
    else:
        raise ActionFormatError("type 只能是 tool_call 或 final")
    return data

Agent:

```python
SYSTEM_PROMPT = """你是一个会使用工具的智能体。每一轮只能输出一个 JSON 对象,不要输出 JSON 之外的任何文字。
可选工具(JSON Schema):
{tools}

输出格式二选一:
1) 调用工具:{ {"type":"tool_call","name":"<工具名>","arguments":{ {...}},"thought":"<一句话>"
2) 给出答案:{ {"type":"final","answer":"<最终答复>"}}
规则:需要外部计算或事实时必须调用工具;收到以 [OBSERVATION] 开头的消息即为工具结果,据此决定下一步。"""

@dataclass
class Step:
index: int
raw: str
action: dict[str, Any] | None
observation: str | None
elapsed_ms: int

@dataclass
class AgentResult:
answer: str
steps: int
trace: list[Step] = field(default

相关文章
|
云安全 安全 网络安全
|
7天前
|
JavaScript 安全 前端开发
Nodejs Brotli decoder 代码执行漏洞 (CVE-2026-111497)10629
Node.js发布安全更新修复Brotli解码器高危RCE漏洞(CVE-2026-111497),建议受影响版本用户立即升级。
2007 1
|
14天前
|
JSON 程序员 数据格式
最新版Notepad++下载、安装、配置保姆级教程(附官网安装包)
Notepad++ 是一款免费开源、轻量快速的 Windows 文本编辑器,支持多标签、语法高亮、代码折叠与宏录制,体积小、启动快、资源占用低,兼容 x64/x32/ARM 架构,是程序员和文字工作者的高效利器。
3160 0
|
13天前
|
人工智能 API 开发工具
DeepSeek Harness 神插件:白嫖 13 家大厂模型
`dsh-our-free-model` 是 DeepSeek Harness 神级插件:零配置、零API Key、零账号,聚合13家大厂免费模型(含DeepSeek V4.1 Flash、Kimi K3等),支持本地网关、用量看板与自动降级,真正实现“白嫖即用”。
2270 44
|
10天前
|
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真正“看懂并验证”前端代码。
1421 3
|
7天前
|
安全 应用服务中间件 Go
PHP2Shell 漏洞拆解:WordPress 与 nginx 为何中招
PHP2Shell 双 RCE 已遭在野利用:WordPress、Drupal 全在射程内,nginx 其实另有洞。拆成因与修复顺序
PHP2Shell 漏洞拆解:WordPress 与 nginx 为何中招
|
8天前
|
开发者 索引
DeepSeekHarness大肥鱼自带插件市场了?加个地址就能用
Open Plugin Market 是一个开放插件索引平台,收录800+优质DSH插件,支持分类浏览、智能推荐与随机发现。无需安装市场,粘贴索引地址即可一键接入,网页版直访 dshopen.org,助力小开发者被看见。(239字)
1710 1
DeepSeekHarness大肥鱼自带插件市场了?加个地址就能用
|
6天前
|
人工智能 Linux API
Magpie, 轻量本地AI网关!免费Token用不完!
Magpie 是开源AI编程模型调度网关,上线一月获1.8万Star。它通过本地高性能网关统一托管多平台API密钥、账号与免费额度,实现跨Agent的模型秒级切换、静默额度轮换与资源复用,显著降低碎片化管理成本。(239字)
Magpie, 轻量本地AI网关!免费Token用不完!
|
6天前
|
安全 程序员 应用服务中间件
PHP 被曝两大致命漏洞,请所有程序员抓紧做这件事!
PHP 官方发布了紧急安全更新,一口气修复了两个严重漏洞。如果网站和服务器用了 PHP 应该怎么处理?漏洞的原理是什么?本文给你讲清楚
698 0

热门文章

最新文章