一个会自我验证的 AI Agent:零框架、零依赖,1300 行标准库代码
摘要市面上讲 AI Agent 的文章十个里有九个在教你搭 LangChain。但真正决定 Agent 能不能干活的往往不是框架选得对不对而是几个非常朴素的工程细节工具的 description 写得好不好、报错信息模型看不看得懂、上下文会不会被一次失败撑爆。这篇文章用一个完整的开源项目 Verialgo 来拆解这些细节。它做一件很具体的事接收算法需求产出被实际执行验证过的 Python 代码。技术栈刻意压到最简纯 Python 标准库pip install一个包都不用手写 agent 循环核心逻辑 149 行同时支持 OpenAI 的chat/completions和responses两套协议命令行 浏览器双前端共用同一套执行引擎仓库地址GitHub - zhang829-666/Verialgo: 一个会写算法代码、并且自己运行验证过的 AI Agent — 零依赖CLI 与浏览器双前端 · GitHub一、为什么要做这个东西先说清楚它解决什么问题。让大模型写一段快排它大概率能写对。但让它写一个支持区间加、区间求和的线段树一次就对的概率并不高——下标差一位、懒标记没下推、边界少一个等号这类错误肉眼极难发现模型自己也不知道写错了。它会把代码信心十足地交给你附一句以上代码经过验证可以正确运行。问题在于模型没有执行过它凭什么说验证过这就是这个项目的出发点。核心思路只有一句话把代码的执行结果重新喂回给模型。有了这一步模型就从生成器变成了闭环系统。它看到IndexError: list index out of range才知道自己错了它知道错了才会去改。真正的实测片段斐波那契数列要求写出递归、迭代、矩阵快速幂三种并对比正确性[1/20] ▸ write_file fibonacci.py [2/20] ▸ run_python fibonacci.py 退出码 1 AssertionError: F(0) 应为 0实际得到 1 [3/20] ▸ write_file fibonacci.py 重写矩阵乘法取右上角而非左上角 [4/20] ▸ run_python fibonacci.py 退出码 0 · 0.05s 完成共 4 步20.3 秒第 2 步的失败正是矩阵快速幂的经典错误——初始向量乘以转移矩阵时取错了元素。这个错误模型第一次没看出来是执行结果告诉它的。这就是整个项目唯一的价值主张。二、整体架构先看全景。整条链路非常直白没有任何隐藏的状态机┌──────────────────────────────────┐ 用户需求 → │ engine.run_task() │ │ │ │ ┌─ 模型推理 ◄──────────┐ │ │ │ │ │ │ ├─ 有 tool_calls │ │ │ │ ├─ 是 → 执行工具 ─┘ │ │ │ └─ 否 → 收工 │ │ └───────────────────────────── │ └────────────┬─────────────────────┘ │ 事件流dict ┌────────────┴────────────┐ ▼ ▼ agent.py终端渲染 web.pySSE 推送关键设计是中间那层事件流。引擎不直接print也不直接写 HTTP它只往外抛事件事件类型载荷含义step{step, total}开始新一轮推理wait{elapsed}还在等模型返回已等 N 秒thought{text}模型的中间思考tool_start{name, hint}即将调用某工具tool_end{name, ok, summary, detail}工具执行完毕final{text, steps, elapsed}任务完成error{message}出错为什么值得这么拆因为同一份循环逻辑两种完全不同的渲染方式终端把它变成彩色文字浏览器把它变成 SSE 事件。加第三个前端比如桌面应用也不需要动引擎一行代码。模块划分agent.py 319 行 命令行入口、自检、参数解析 engine.py 149 行 执行引擎事件的唯一来源 tools.py 256 行 四个工具 路径防护 llm.py 206 行 模型通信双协议自适应 web.py 265 行 HTTP 服务 SSE config.py 93 行 配置加载 prompt.py 34 行 系统提示词三、核心循环149 行讲完去掉事件上报的样板代码agent 循环的本质就是下面这段def run_task(task: str, cfg, max_steps: int, emit) - str: started time.monotonic() session llm.Session(cfg, prompt_mod.SYSTEM_PROMPT, tools.TOOL_SCHEMAS) session.add_user(prompt_mod.build_task_prompt(task)) for step in range(1, max_steps 1): emit({type: step, step: step, total: max_steps}) with _Ticker(emit): # 等待期间定时上报防止前端像卡死 text, calls session.send() if text: emit({type: thought, text: text}) if not calls: # 不再要工具 任务结束 emit({type: final, text: text, steps: step, elapsed: round(time.monotonic() - started, 1)}) return text for call in calls: result safe_call(call) # 内部 try/except见下 session.add_tool_result(call[id], result) # 结果回灌safe_call是为篇幅做的封装实际代码里就是下面那段 try/except。三个必须有的东西1.max_steps硬上限。真实项目里死循环是最常见的事故。而且每一轮循环都要把完整上下文重新发给模型token 按轮数线性增长不设上限就是烧钱。这版默认 16 步上限 60。2. 上下文必须原样累积。上一轮的 assistant 消息含tool_calls和每个工具的返回结果都要按协议要求的格式 append 回去。少一条模型就会不认账表现为重复调用同一个工具或者答非所问。3. 单个工具崩掉不能拖垮整个循环。任何异常都要转成字符串回传让模型自己决定下一步try: args _parse_arguments(call.get(arguments)) result tools.call_tool(name, args) except (ValueError, tools.ToolError) as exc: result f工具执行失败{exc} except Exception as exc: # 兜底别让单个工具崩掉整个循环 result f工具意外报错{type(exc).__name__}: {exc}四、工具设计description 就是提示词这是我认为最被低估的一环。模型凭什么决定调不调工具、传什么参数全部依据就是你写的 description。写检索文档和写问题涉及内部资料、历史记录时必须调用命中率完全是两个量级。这个项目只有四个工具但每个的 description 都是反复打磨过的{ type: function, function: { name: run_python, description: ( 用 Python 解释器实际执行 workspace 内的一个 .py 文件返回退出码、stdout 和 stderr。 写完代码必须调用它验证不要靠推测下结论。默认工作目录就是 workspace。 ), ... } }注意最后那句写完代码必须调用它验证不要靠推测下结论。它不是描述工具功能而是在给模型下指令。实测下来加不加这句模型写完就交差的比例差别非常明显。报错要写成模型能看懂的话工具报错时直接把 traceback 丢回去是最省事的做法也是最没用的做法。应该在工具层就把错误翻译成人话if not target.is_file(): siblings sorted(p.name for p in target.parent.glob(*)) hint f该目录下现有{, .join(siblings[:20])} if siblings else 所在目录为空。 raise ToolError(f文件 {path} 不存在。{hint})模型收到的是文件 a.py 不存在。该目录下现有b.py, c.py它就知道自己文件名写错了下一步会改成b.py。而如果收到的是FileNotFoundError: [Errno 2] No such file or directory: a.py它可能要浪费两三轮才能反应过来。这个差别比换一个更强的模型还管用。输出截断一次失败的运行可能打印几千行报错。全塞进上下文轻则浪费 token重则把关键信息挤出窗口。这里的策略是保头保尾、中间省略def _clip(text: str) - str: if len(text) MAX_OUTPUT_HEAD MAX_OUTPUT_TAIL 60: return text omitted len(text) - MAX_OUTPUT_HEAD - MAX_OUTPUT_TAIL return (f{text[:MAX_OUTPUT_HEAD]}\n f...输出过长中间省略 {omitted} 个字符...\n f{text[-MAX_OUTPUT_TAIL:]})头部 3000 字符模型通常需要的上下文和报错起点、尾部 800 字符AssertionError和最终退出状态往往在这。执行环境的两个细节env[PYTHONIOENCODING] utf-8 env[PYTHONUTF8] 1 cmd [sys.executable, -u, str(target), *extra]-u强制不缓冲否则子进程的输出可能拿不到UTF-8 环境变量是为了中文注释和中文输出不乱码。这两个不加在 Windows 上会踩坑。五、提示词里的禁令工具给了能力提示词给了边界。这个项目的系统提示词里最有价值的部分是禁止那一节## 绝对禁止 - 禁止说代码应该可以运行、经过分析是正确的这类未经执行验证的结论。 - 禁止用 ...、pass # TODO、伪代码代替真实实现。 - 禁止跳过测试直接交付。 - 禁止使用绝对路径或操作 workspace 之外的文件。第一条针对的是模型最根深蒂固的习惯——它倾向于用语言掩盖不确定性。不禁掉它照样会给你一句以上代码已验证。第二条针对偷懒。不写这条你会收到一堆# 此处省略具体实现。第三条是把测试变成交付物的强制组成部分。项目要求模型自己在if __name__ __main__:里用assert覆盖正常情况、空输入、单元素、重复值、极端值并打印PASS。实际效果让它写并查集产出 380 行代码里有 8 个独立测试函数覆盖了空输入、自环、非法参数、路径压缩的白盒验证、2 万元素长链以及固定种子的随机对拍。这不是模型自觉是提示词逼出来的。六、双协议适配一个少见但很痛的坑这一节可能是全文最有实操价值的部分。大多数教程默认所有 OpenAI 兼容网关都提供/chat/completions。实际上有相当一部分网关只开放/responses——就是 OpenAI 的 Responses APICodex 系列工具走的就是这条路。两者的差异远不止 URL差异点chat/completionsresponses系统提示messages[0]的 system 角色顶层instructions字段历史结构扁平messages数组input数组元素带type工具声明嵌套{type, function: {name, ...}}扁平{type, name, description, parameters}工具结果{role: tool, tool_call_id}{type: function_call_output, call_id}返回结构choices[0].messageoutput数组按type分派调用 IDtool_calls[].idfunction_call.call_id如果只按 chat 协议写死了换到这类型关上会直接 404报错还是尚未启用本机 API 路由这种看不出所以然的文案。解决方式是做一个Session抽象内部同时维护两套历史上层调用完全无感class Session: def __init__(self, cfg, system_prompt, tools): self.mode None if cfg.api_mode auto else cfg.api_mode self._chat [{role: system, content: system_prompt}] self._resp [] def send(self): if self.mode in (None, chat): try: return self._send_chat() except LLMError: if self.mode chat: raise self.mode responses # 该网关没开 chat 路由切协议重试 return self._send_responses()auto模式的逻辑是先按 chat 发如果返回 404/405/501 或者错误里含尚未启用这类关键字就判定为路由缺失自动切到 responses 重发一次。识别路由缺失的关键字表也值得一提因为各家网关文案完全不统一ROUTE_MISSING_HINTS (not_configured, 尚未启用, not found, unknown url, 不支持)responses 协议的返回解析要按type分派且必须把reasoning、message、function_call都原样回灌——这是协议要求少了下一轮会报错for item in data.get(output) or []: kind item.get(type) if kind message: for part in item.get(content) or []: if part.get(type) in (output_text, text): texts.append(part.get(text) or ) replay.append(item) elif kind function_call: calls.append({id: item.get(call_id) or item.get(id, ), name: item.get(name, ), arguments: item.get(arguments) or {}}) replay.append(item) self._resp.extend(replay)做完这层同一份代码既能连 DeepSeek 官方、Ollama、vLLM也能连只开 responses 的网关切换只需要改一行.env。七、浏览器前端SSE 而不是轮询既然是长任务一次跑 20 秒以上前端必须能实时看到进度。方案是 SSEServer-Sent Events不用 WebSocket不用轮询。服务端核心就三件事取事件、写、flush。self.send_response(200) self.send_header(Content-Type, text/event-stream; charsetutf-8) self.send_header(Cache-Control, no-cache, no-transform) self.send_header(Connection, keep-alive) self.send_header(X-Accel-Buffering, no) # 关键禁止中间层缓冲 self.end_headers() while True: try: event channel.get(timeout15) except queue.Empty: self.wfile.write(b: keep-alive\n\n) # 注释行维持连接 self.wfile.flush() continue if event.get(type) __end__: self.wfile.write(bevent: end\ndata: {}\n\n) self.wfile.flush() break payload json.dumps(event, ensure_asciiFalse) self.wfile.write(fdata: {payload}\n\n.encode(utf-8)) self.wfile.flush() # 不 flush 就等于没推几个容易忽略的点每次写完必须flush()。不 flush数据会攒在缓冲区前端表现就是什么反应都没有最后一次性全出来——和死机一模一样。X-Accel-Buffering: no。如果哪天放进 Nginx 后面不加这个头Nginx 会缓冲整个响应SSE 直接失效。15 秒超时发心跳。纯注释行: keep-alive浏览器会忽略内容但保持连接存活避免被中间设备掐断。BrokenPipeError要吞掉。用户关页面是正常行为不该在服务端打一堆红色 traceback。架构上任务跑在独立线程里事件投进queue.QueueSSE 从队列里取def start_task(task, max_steps): task_id uuid.uuid4().hex[:12] channel queue.Queue() with TASKS_LOCK: TASKS[task_id] channel def worker(): try: engine.run_task(task, cfg_mod.load_config(), max_steps, channel.put) except Exception as exc: channel.put({type: error, message: f{type(exc).__name__}: {exc}}) finally: channel.put({type: __end__}) # 无论成败都要收尾 threading.Thread(targetworker, daemonTrue).start() return task_id用队列而不是直接操作 socket好处是请求线程和任务线程彻底解耦模型想 30 秒SSE 连接照样能发心跳浏览器中途关掉任务也不受影响虽然结果没人接。前端是单个 HTML 文件514 行零依赖、零构建。左边时间线滚动展示每一步右边列出workspace/里的产物点开能看带行号和语法高亮的代码。八、安全边界必须明确说这个 Agent 执行的是模型生成的代码跑在你的用户权限下。所以边界是从工具层硬拦的不依赖模型自觉。路径锁死def _resolve(rel: str) - Path: raw (rel or ).strip().replace(\\, /) if raw.startswith(/) or (len(raw) 1 and raw[1] :): raise ToolError(f不要使用绝对路径收到 {rel}请写相对于 workspace 的路径) target (WORKSPACE / raw).resolve() try: target.relative_to(WORKSPACE) # resolve 之后再校验防 ../ 绕过 except ValueError: raise ToolError(f路径 {rel} 超出了工作区范围只允许操作 workspace/ 内的文件。) return target关键是先resolve()再校验。如果只做字符串匹配../workspace/foo/../../etc/passwd这类写法可以绕过先解析成绝对路径再判断是否在根目录下才是可靠的。其他限制限制值目的单次执行超时默认 20s上限 60s死循环不挂住机器单任务步数默认 16上限 60不无限烧 token输出长度头 3000 尾 800 字符不撑爆上下文文件列表最多 200 条同上子进程 stdinDEVNULL防止input()卡住自检里专门有一项是测越界拦截的——python agent.py --selftest会真跑一次../访问确认被拒。九、踩过的坑Windows 环境这部分是纯经验网上资料很少但每一个都能让人卡半天。坑 1python命令是个 0 字节的空壳现象在 PowerShell 里执行python agent.py没输出、没报错、直接返回像什么都没发生。原因路径C:\Users\xxx\AppData\Local\Microsoft\WindowsApps\python.exe是微软商店的应用执行别名文件大小0 字节。执行它既不运行也不报错静默失败。检查方法Get-Command python (Get-Item (Get-Command python).Source).Length如果长度是 0就是它。解决办法设置 → 应用 → 高级应用设置 → 应用执行别名关掉python.exe和python3.exe。这个坑的迷惑性在于——它看起来完全像程序卡住或输出缓冲问题会让人往错误的方向排查很久。坑 2.bat文件里不能写中文现象双击run.batcmd 报xxx 不是内部或外部命令。原因cmd 按 GBK 解析批处理文件UTF-8 的中文注释被解成乱码其中某些字节恰好被当成命令执行。铁律.bat/.cmd文件保持纯 ASCII。注释用英文输出提示让 Python 去打。坑 3输出缓冲导致假死即使解释器正常stdout 被重定向时比如管道、IDE 内置终端默认是块缓冲提示信息会攒到程序结束才一次性吐出。三处一起修for stream in (sys.stdout, sys.stderr): try: if stream.isatty(): stream.reconfigure(line_bufferingTrue) # 控制台保留原生编码 else: stream.reconfigure(line_bufferingTrue, encodingutf-8, errorsreplace) except (AttributeError, OSError, ValueError): pass注意isatty()的分流控制台保留系统原生编码中文 Windows 下是 GB2312只在重定向时才强制 UTF-8。无差别强制 UTF-8 会导致中文 Windows 控制台乱码。坑 4cmd 不认\033[K想在原地刷新已等待 N 秒用 ANSI 清行符\033[K在 PowerShell 里正常在 cmd 里变成字面量堆成一行垃圾。换成最朴素的方案\r 空格覆盖。sys.stdout.write(\r * 32 \r)坑 5端口占用web.py启动时如果 8765 被占不用报错退出自动往后试 12 个for candidate in range(port, port 12): try: server Server((127.0.0.1, candidate), Handler) port candidate break except OSError: continue十、实测数据以下都是真实运行结果不是估算。任务步数耗时产出是否发生自我纠错并查集路径压缩 按秩合并324s380 行8 个测试函数否斐波那契三种写法420s含对拍验证是矩阵取错元素线段树区间加 区间和323s约 400 行否二分查找417s先list_files再动手否冒泡排序要求稳定——15 个 SSE 事件正常收尾否自检项python agent.py --selftest不消耗 token[1/5] write_file 写入测试文件 ✓ [2/5] run_python 执行10 万以内质数个数 9592 校验 ✓ [3/5] read_file 带行号回读 ✓ [4/5] 路径越界拦截../ ✓ [5/5] 缺失文件的友好提示 ✓关于速度用的是推理模型单次思考 10–30 秒是正常的。简单任务整体 20 秒左右复杂任务可能一两分钟。VERIALGO_TIMEOUT别设太小。十一、这个方案的局限诚实地说清楚不做什么比吹能做什么更有价值。1. 它不解决模型能力问题。如果模型太弱反复改都改不对改代码没用换模型才有用。2. 单任务不并行。一次只跑一个循环没有子任务分解。想做多智能体协同时这版是基础而不是终态。3. 没有评测集。真实项目必须有一批固定测试用例jsonl每条带期望结果记录三个指标是否通过、用了多少步、花了多少 token。改提示词前后各跑一次通过率下降就回滚——不要相信我改完感觉好多了这种直觉判断。这版留了接口但没实现。4. 没有持久化。会话结束即遗忘不能跨任务积累经验。5. 只支持 Python。工具是硬编码的run_python换语言要加工具。追加工具的方式很直接在tools.py写函数 → 加 schema → 注册到_DISPATCH字典三步。十二、怎么跑起来git clone https://github.com/zhang829-666/Verialgo.git cd Verialgo cp .env.example .env # 填 base_url 和 api_key python agent.py --selftest python agent.py 实现一个支持区间加和区间求和的线段树浏览器版python web.py # 自动打开 http://127.0.0.1:8765配置项变量默认说明OPENAI_BASE_URLhttp://127.0.0.1:3000/v1接口地址OPENAI_API_KEY空密钥VERIALGO_MODELdeepseek-chat模型名VERIALGO_API_MODEautoauto/chat/responsesVERIALGO_MAX_STEPS16最大步数VERIALGO_TIMEOUT180请求超时秒十三、小结回过头看让这个 Agent 能干活的东西按重要性排序run_python返回原始报错——这是闭环的来源没有它什么都不是工具 description 里写指令——决定模型会不会主动验证提示词里的禁止——堵住模型用语言掩盖不确定性的本能报错翻译成人话——让纠错能在一两轮内完成而不是五六轮max_steps硬上限——保证它不会失控框架能帮你省下的是第 6 位以后的事情。如果你的目标是学习 Agent 到底怎么运转我的建议是先把一个 80 行的循环跑通再往上加东西。不要一上来就上 LangGraph、CrewAI——单智能体加三个工具都跑不稳的时候多智能体只会让状态更难追踪。等单智能体能稳定跑通业务、并且出现明确的分工需求了再拆。项目地址GitHub - zhang829-666/Verialgo: 一个会写算法代码、并且自己运行验证过的 AI Agent — 零依赖CLI 与浏览器双前端 · GitHub如果对你有用欢迎点个 Star有问题也欢迎在评论区或 Issue 里聊。