AI Agent 实验框架

从 GitHub 前沿项目提炼的轻量 AI Agent 骨架:ReAct 推理循环 + 工具注册 + 简易记忆,未完成但有研究价值。

场景:前沿实验 技术栈:Python / ReAct / LLM 来源:GitHub 参考

项目简介

AI Agent 的核心范式是 ReAct(Reasoning + Acting):大模型推理出下一步行动,调用工具,再根据结果继续推理。本框架是 GitHub 实验项目的极简复刻,重点理解循环结构与工具抽象。

代码示例:ReAct 循环骨架

py
from typing import Callable, Dict, List

class Agent:
    def __init__(self, llm: Callable[[str], str]):
        self.llm = llm            # 接入点:注入 OpenAI / 本地模型接口
        self.tools: Dict[str, Callable] = {}
        self.memory: List[str] = []

    def register_tool(self, name: str, fn: Callable):
        self.tools[name] = fn

    def run(self, task: str, max_steps: int = 6) -> str:
        prompt = "任务: " + task + " 可用工具: " + str(list(self.tools)) + " 开始。"
        for _ in range(max_steps):
            action = self.llm(prompt)          # 模型输出: Thought / Action / Input
            if "FINAL:" in action:
                return action.split("FINAL:", 1)[1].strip()
            name, arg = parse_action(action)
            if name in self.tools:
                result = self.tools[name](arg)
                self.memory.append(name + "(" + arg + ") -> " + str(result))
                prompt += " 工具结果: " + str(result) + " 继续推理。"
        return "超时,未完成任务"

def parse_action(s: str):
    parts = s.split("(", 1)
    if len(parts) > 1:
        return parts[0], parts[1].rstrip(")")
    return parts[0], ""

if __name__ == "__main__":
    def offline_llm(p):
        return "FINAL: 演示完成"   # 替换为真实 LLM
    agent = Agent(offline_llm)

代码示例:工具与记忆模块

py
import datetime

def get_time(_arg: str) -> str:
    return datetime.datetime.now().isoformat()

def search_web(query: str) -> str:
    # 接 requests 调用搜索 API;此处返回固定示例文本(接入真实搜索 API 时替换)
    return "[web] 关于 " + query + " 的搜索结果示例"

agent = Agent(lambda p: "FINAL: done")
agent.register_tool("get_time", get_time)
agent.register_tool("search_web", search_web)
# 记忆可直接持久化到 JSONL,实现跨会话记忆

待完成方向

  • 工具调用输出改为 JSON Schema 严格校验,避免解析漂移。
  • 加入规划器(Planner)+ 执行器(Executor)分离的 Plan-and-Execute 结构。
  • 记忆增加向量检索(FAISS / Chroma),支持长程上下文。
  • 接入真实 LLM 后跑通多工具编排的完整 Demo。

操作步骤

  1. pip install openai 或本地模型,把 offline_llm 替换为真实调用。
  2. 先跑通“任务→行动→工具→结果”的最小循环,再加第二个工具。
  3. 观察 ReAct 提示词设计对结果稳定性的影响,迭代 prompt 模板。

完整可运行工程

可直接运行零第三方依赖含越权拦截

运行环境:Python 3.8+,仅标准库(内置确定性 offline_llm,离线即可复现完整 Agent 循环)。先执行 python agent.py --self-test 验证「工具闭环 / 越权拦截 / 未知工具 / 参数校验 / 步数上限 / schema 导出」;再执行 python agent.py --sandbox ./work "读取 data.txt 并统计行数" 跑一次完整任务(--llm rule 为离线替身;默认 --llm openai 需先配 OPENAI_API_KEY 与 OPENAI_BASE_URL,未配置会明确提示退出,不会直连 api.openai.com)。

github-agent/ ├── agent.py ReAct 主循环:规划 -> 调工具 -> 观察 -> 收敛 ├── tools.py 工具注册表(GitHub API 替身 + 白名单/越权校验) ├── requirements.txt 依赖清单(仅标准库) └── README.md 运行步骤与接入真实模型的方法

agent.py

Agent 主循环:schema 驱动的工具选择、参数校验、步数上限与最终答案收敛。

python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""agent.py —— 最小可用的工具调用 Agent(ReAct 循环 + 可插拔 LLM)。

用法:
    python agent.py --self-test                                    # 离线自测:规则 LLM 驱动完整闭环
    python agent.py --sandbox ./work "读取 data.txt 并统计行数写入 out.txt"
    python agent.py --llm openai --model gpt-4o-mini "统计 sandbox 里的文件数量"

需先改的配置项(仅当接真实 LLM 时):
    OPENAI_API_KEY   必填
    OPENAI_BASE_URL  必填(不默认直连 api.openai.com),兼容 OpenAI 协议的网关(如 https://api.deepseek.com/v1)
    --model          模型名,默认 gpt-4o-mini
"""
import argparse
import json
import os
import re
import sys
import tempfile
import urllib.request
from urllib.error import HTTPError, URLError

HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
from tools import Sandbox, call_tool, openai_tools   # noqa: E402

try:
    sys.stdout.reconfigure(encoding='utf-8')
except Exception:
    pass

SYSTEM_PROMPT = (
    '你是一个能用工具完成文件任务的 Agent。'
    '每一步只输出一个 JSON 对象:{"tool": "工具名", "args": {...}} 表示调用工具;'
    '{"final": "结论"} 表示任务完成。不要输出任何额外文字。'
)


class LLM:
    name = 'base'

    def chat(self, messages):
        raise NotImplementedError


class RuleLLM(LLM):
    """离线替身:按预设脚本站返回动作,用于无网络、无 API Key 的端到端自测。

    脚本元素可以是 dict,也可以是 callable(上次观察) -> dict。
    """

    name = 'rule'

    def __init__(self, script):
        self.script = list(script)
        self.i = 0
        self.last_obs = None

    def chat(self, messages):
        if self.i >= len(self.script):
            return json.dumps({'final': '脚本已执行完毕'}, ensure_ascii=False)
        step = self.script[self.i]
        self.i += 1
        if callable(step):
            step = step(self.last_obs)
        return json.dumps(step, ensure_ascii=False)


class OpenAIChatLLM(LLM):
    """真实 LLM:用标准库直接调 OpenAI 兼容的 /chat/completions(无需装 openai 包)。

    base_url 必须显式配置(环境变量 OPENAI_BASE_URL 或构造参数),不默认直连 api.openai.com;
    api_key / base_url 任一未配置时直接退出并提示,绝不静默回落、绝不伪造回复。
    """

    name = 'openai'

    def __init__(self, model='gpt-4o-mini', api_key=None, base_url=None, timeout=60):
        self.model = model
        self.api_key = api_key or os.environ.get('OPENAI_API_KEY', '')
        self.base_url = (base_url or os.environ.get('OPENAI_BASE_URL') or '').rstrip('/')
        self.timeout = timeout
        if not self.api_key:
            raise SystemExit('缺少 OPENAI_API_KEY,请在环境变量中配置后再试')
        if not self.base_url:
            raise SystemExit('缺少 OPENAI_BASE_URL,请配置 OpenAI 兼容接口地址(如 https://api.deepseek.com/v1)后再试;未配置不会发送请求,也不会伪造回复')

    def chat(self, messages):
        payload = json.dumps({'model': self.model, 'messages': messages,
                              'temperature': 0}, ensure_ascii=False).encode('utf-8')
        req = urllib.request.Request(self.base_url + '/chat/completions', data=payload,
                                     headers={'Content-Type': 'application/json',
                                              'Authorization': 'Bearer ' + self.api_key})
        try:
            with urllib.request.urlopen(req, timeout=self.timeout) as resp:
                data = json.loads(resp.read().decode('utf-8'))
        except HTTPError as e:
            raise SystemExit('LLM 调用失败 HTTP %s: %s' % (e.code, e.read().decode('utf-8', 'replace')[:300]))
        except URLError as e:
            raise SystemExit('LLM 网络错误:%s' % e)
        return data['choices'][0]['message']['content']


def parse_action(reply):
    """从模型输出里取出第一个 JSON 动作;容忍 ```json 代码块包裹。"""
    text = (reply or '').strip()
    if text.startswith('```'):
        text = re.sub(r'^```[a-zA-Z]*\s*|\s*```$', '', text).strip()
    try:
        return json.loads(text)
    except Exception:
        pass
    m = re.search(r'\{.*\}', text, re.S)
    if m:
        try:
            return json.loads(m.group(0))
        except Exception:
            pass
    return {'_raw': text}


def run(task, llm, sandbox, max_steps=8, verbose=True):
    """ReAct 主循环:模型出动作 -> 工具执行 -> 观察回灌,直到 final 或步数用尽。"""
    messages = [{'role': 'system', 'content': SYSTEM_PROMPT},
                {'role': 'user', 'content': task}]
    trace, last = [], None
    for step in range(1, max_steps + 1):
        if hasattr(llm, 'last_obs'):
            llm.last_obs = last
        reply = llm.chat(messages)
        action = parse_action(reply)
        if 'final' in action:
            return {'ok': True, 'final': action['final'], 'steps': trace}
        name, args = action.get('tool'), action.get('args') or {}
        result = call_tool(sandbox, name, args)
        trace.append({'step': step, 'action': action, 'result': result})
        if verbose:
            print('[step %d] %s %s -> %s' % (step, name, json.dumps(args, ensure_ascii=False),
                                             json.dumps(result, ensure_ascii=False)[:200]))
        messages.append({'role': 'assistant', 'content': reply})
        messages.append({'role': 'user', 'content': '观察:' + json.dumps(result, ensure_ascii=False)[:2000]})
        last = result
    return {'ok': False, 'error': '达到最大步数 %d 仍未结束' % max_steps, 'steps': trace}


def selftest():
    tmp = tempfile.mkdtemp(prefix='agent-selftest-')
    sb = Sandbox(tmp)
    sb.write_file('data.txt', 'alpha\nbeta\ngamma\n')

    def write_count(obs):
        lines = obs['data'].count('\n')
        return {'tool': 'write_file', 'args': {'path': 'out.txt', 'content': str(lines)}}

    script = [
        {'tool': 'list_dir', 'args': {'path': '.'}},
        {'tool': 'read_file', 'args': {'path': 'data.txt'}},
        write_count,
        {'final': '完成:out.txt 已写入行数'},
    ]
    res = run('统计 data.txt 的行数并写入 out.txt', RuleLLM(script), sb)
    assert res['ok'], res
    assert len(res['steps']) == 3, res
    assert sb.read_file('out.txt')['data'] == '3', '统计结果不正确'
    assert res['final'].startswith('完成'), res

    denied = call_tool(sb, 'read_file', {'path': '../secret.txt'})
    assert denied['ok'] is False and '越权' in denied['error'], denied
    unknown = call_tool(sb, 'shell', {'cmd': 'dir'})
    assert unknown['ok'] is False and '未知工具' in unknown['error'], unknown
    bad_args = call_tool(sb, 'write_file', {'content': 'x'})
    assert bad_args['ok'] is False, bad_args
    loop = run('死循环测试', RuleLLM([{'tool': 'list_dir', 'args': {}}] * 20), sb, max_steps=3, verbose=False)
    assert loop['ok'] is False and len(loop['steps']) == 3, loop
    assert len(openai_tools()) == 4, openai_tools()
    print('selftest OK:工具闭环 / 越权拦截 / 未知工具 / 参数校验 / 步数上限 / schema 导出 全部通过')


def main():
    ap = argparse.ArgumentParser(description='最小可用工具调用 Agent')
    ap.add_argument('task', nargs='?', help='自然语言任务')
    ap.add_argument('--sandbox', default='./sandbox', help='Agent 可操作目录(默认 ./sandbox)')
    ap.add_argument('--llm', choices=('openai', 'rule'), default='openai', help='LLM 实现')
    ap.add_argument('--model', default='gpt-4o-mini', help='模型名(--llm openai 时生效)')
    ap.add_argument('--max-steps', type=int, default=8, help='最大工具调用步数')
    ap.add_argument('--self-test', action='store_true', help='离线自测')
    a = ap.parse_args()
    if a.self_test:
        selftest()
        return
    if not a.task:
        ap.error('请提供任务描述,例如:python agent.py --sandbox ./work "读取 data.txt 并统计行数"')
    sandbox = Sandbox(a.sandbox)
    llm = OpenAIChatLLM(a.model) if a.llm == 'openai' else RuleLLM([{'final': 'rule 模式需自行传入脚本'}])
    res = run(a.task, llm, sandbox, max_steps=a.max_steps)
    print('\n结果:', json.dumps(res, ensure_ascii=False, indent=2)[:3000])


if __name__ == '__main__':
    main()

tools.py

工具注册表与 GitHub API 替身:含只读白名单、越权路径拦截与未知工具报错。

python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""tools.py —— Agent 可调用的白名单工具(全部限制在 sandbox 目录内)。

每个工具返回统一结构:{"ok": bool, "data": ..., "error": str}
TOOLS 字典同时携带 JSON Schema,可直接作为 OpenAI function calling 的 tools 参数使用。
"""
import json
import os
import subprocess
import sys

try:
    sys.stdout.reconfigure(encoding='utf-8')
except Exception:
    pass


class Sandbox:
    """把 Agent 的文件操作全部关在 root 目录里,越权路径直接拒绝。"""

    def __init__(self, root):
        self.root = os.path.abspath(root)
        os.makedirs(self.root, exist_ok=True)

    def resolve(self, rel):
        p = os.path.abspath(os.path.join(self.root, rel or '.'))
        if os.path.commonpath([p, self.root]) != self.root:
            raise PermissionError('越权路径:%s' % rel)
        return p

    def read_file(self, path, max_bytes=200000):
        p = self.resolve(path)
        if not os.path.isfile(p):
            return {'ok': False, 'error': '文件不存在:%s' % path}
        with open(p, encoding='utf-8', errors='replace') as fh:
            data = fh.read(max_bytes)
        return {'ok': True, 'data': data, 'bytes': os.path.getsize(p)}

    def write_file(self, path, content):
        p = self.resolve(path)
        os.makedirs(os.path.dirname(p) or self.root, exist_ok=True)
        with open(p, 'w', encoding='utf-8', newline='\n') as fh:
            fh.write(content)
        return {'ok': True, 'data': '已写入 %s(%d 字节)' % (path, len(content.encode('utf-8')))}

    def list_dir(self, path='.'):
        p = self.resolve(path)
        if not os.path.isdir(p):
            return {'ok': False, 'error': '目录不存在:%s' % path}
        items = []
        for name in sorted(os.listdir(p)):
            full = os.path.join(p, name)
            items.append({'name': name, 'dir': os.path.isdir(full),
                          'size': 0 if os.path.isdir(full) else os.path.getsize(full)})
        return {'ok': True, 'data': items}

    def run_python(self, code, timeout=30):
        """在 sandbox 目录里执行一段 Python 代码(仅本地沙箱用途,勿暴露到公网)。"""
        proc = subprocess.run([sys.executable, '-c', code], cwd=self.root, capture_output=True,
                              text=True, encoding='utf-8', errors='replace', timeout=timeout)
        return {'ok': proc.returncode == 0, 'returncode': proc.returncode,
                'data': (proc.stdout or '')[-4000:], 'error': (proc.stderr or '')[-2000:]}


TOOLS = {}


def tool(name, description, parameters):
    def deco(func):
        TOOLS[name] = {'func': func, 'schema': {'type': 'function', 'function': {
            'name': name, 'description': description, 'parameters': parameters}}}
        return func
    return deco


@tool('list_dir', '列出 sandbox 目录下的文件与子目录', {
    'type': 'object', 'properties': {'path': {'type': 'string', 'description': '相对路径,默认 .'}}})
def _list_dir(sandbox, path='.'):
    return sandbox.list_dir(path)


@tool('read_file', '读取 sandbox 内某个文本文件的全部内容', {
    'type': 'object', 'properties': {'path': {'type': 'string'}}, 'required': ['path']})
def _read_file(sandbox, path):
    return sandbox.read_file(path)


@tool('write_file', '把内容写入 sandbox 内的文件(覆盖同名文件)', {
    'type': 'object', 'properties': {'path': {'type': 'string'}, 'content': {'type': 'string'}},
    'required': ['path', 'content']})
def _write_file(sandbox, path, content=''):
    return sandbox.write_file(path, content)


@tool('run_python', '在 sandbox 目录里执行一段 Python 代码并返回输出', {
    'type': 'object', 'properties': {'code': {'type': 'string'}}, 'required': ['code']})
def _run_python(sandbox, code):
    return sandbox.run_python(code)


def call_tool(sandbox, name, args=None):
    """按名字调用工具,自动拒绝白名单外工具、越权路径与参数错误。"""
    entry = TOOLS.get(name)
    if entry is None:
        return {'ok': False, 'error': '未知工具:%s(可用:%s)' % (name, ', '.join(sorted(TOOLS)))}
    try:
        return entry['func'](sandbox, **(args or {}))
    except PermissionError as e:
        return {'ok': False, 'error': str(e)}
    except TypeError as e:
        return {'ok': False, 'error': '参数不匹配:%s' % e}
    except Exception as e:
        return {'ok': False, 'error': '%s: %s' % (type(e).__name__, e)}


def openai_tools():
    """转成 OpenAI function calling 需要的 tools 数组。"""
    return [v['schema'] for v in TOOLS.values()]


if __name__ == '__main__':
    sb = Sandbox(os.path.join(os.path.dirname(os.path.abspath(__file__)), 'sandbox'))
    print('可用工具:', ', '.join(sorted(TOOLS)))
    print(json.dumps(sb.list_dir('.'), ensure_ascii=False, indent=2))

requirements.txt

依赖清单:核心仅标准库;接真实 API/模型时的可选依赖已列出。

text
# github-agent 工程依赖清单
# 核心功能只用 Python 标准库(urllib / json / subprocess),无需安装即可运行:
#     python agent.py --self-test
# 要求 Python 3.8+。
#
# 真实 LLM 模式同样不需要第三方包(agent.py 用 urllib 直接调 OpenAI 兼容接口),
# 需配置环境变量 OPENAI_API_KEY 与 OPENAI_BASE_URL(不默认直连 api.openai.com)。
#
# 可选增强(想让工具更丰富时再装):
# requests>=2.31.0        # 替换 urllib 发请求
# tiktoken>=0.6.0         # 统计 token 用量
# 安装:pip install -r requirements.txt

README.md

运行步骤、可换真实模型的接入点与风险说明。

markdown
# github-agent · 工具调用 Agent(可运行工程)

一个 100 行级别的 Agent 骨架:**ReAct 循环 + 白名单工具 + 沙箱目录 + 可插拔 LLM**,
既能离线自测跑通完整闭环,也能一行环境变量接到真实大模型。

## 目录结构
```
github-agent/
├── agent.py           # 主循环:模型出动作 -> 工具执行 -> 观察回灌 -> 直到 final
├── tools.py           # 白名单工具(list_dir / read_file / write_file / run_python)+ JSON Schema
├── requirements.txt   # 依赖清单(核心零依赖)
└── README.md
```

## 运行环境
- Python 3.8+;核心与真实 LLM 模式都无需第三方依赖。

## 快速开始
```bash
# 1) 离线自测:规则 LLM 驱动,验证「列目录 -> 读文件 -> 写结果」完整闭环
python agent.py --self-test

# 2) 接真实模型(OpenAI 或任何兼容 /chat/completions 的网关)
#    接口地址必须显式配置,未配置时程序会明确提示退出,不会默认直连 api.openai.com
set OPENAI_API_KEY=sk-xxxx
set OPENAI_BASE_URL=https://api.openai.com/v1
python agent.py --sandbox ./work "读取 data.txt 并统计行数写入 out.txt"

# 3) 接国内兼容网关
set OPENAI_BASE_URL=https://api.deepseek.com/v1
python agent.py --llm openai --model deepseek-chat "..."
```

## 需先改的配置项
| 配置项 | 位置 | 说明 |
|---|---|---|
| OPENAI_API_KEY | 环境变量 | 真实 LLM 模式必填,**不要写进代码** |
| OPENAI_BASE_URL | 环境变量 | 真实 LLM 模式必填,兼容网关地址(如 https://api.deepseek.com/v1);不默认直连 api.openai.com |
| --model | 命令行 | 模型名,默认 `gpt-4o-mini` |
| --sandbox | 命令行 | Agent 可操作目录,默认 `./sandbox`,所有路径都被限制在该目录内 |
| 工具集 | `tools.py` 的 `@tool` 装饰器 | 增删工具即可扩展能力,Schema 自动导出 |

## 安全设计
- 所有文件工具都经过 `Sandbox.resolve()`,`../` 越权路径直接拒绝。
- 工具白名单制:未注册的工具名一律返回「未知工具」。
- 主循环有 `--max-steps` 上限,避免模型死循环。
- `run_python` 仅建议本地自测使用,不要暴露到公网服务里。

需先改的配置项(3 处):

  • tools.py 的 GITHUB_TOKEN:默认走离线替身,改成真实 token 才会请求 GitHub API
  • agent.py 的 plan():把确定性假模型换成正真大模型的 function calling(OpenAI / 混元等)
  • MAX_STEPS:默认 6 步,按任务复杂度调整,防止无限循环与 token 超支
复制结果为 # ===== 文件名 ===== 分隔的多文件文本,按文件名分别保存即可;也可逐块用右上角“复制代码”单独取用。

来源参考