十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Agent Runtime三端一致性:Web/Headless/Python SDK架构解耦与契约统一

Agent Runtime三端一致性:Web/Headless/Python SDK架构解耦与契约统一 1. 项目概述一套 Runtime三种“面孔”背后的本质矛盾你有没有遇到过这样的场景团队里一个同学在 Slack 里发截图说“Web 端调用 agent 成功了返回结果很干净”隔壁工位的同事却皱着眉头敲命令行“headless 模式下跑同样的 prompt直接卡在 plugin 加载阶段报错error: agent harness runtime codex is unavailable because its plugin regis...”而 Python SDK 的使用者更是一头雾水——明明 pip install 了最新版from agent_runtime import Agent能导入但一执行.run()就抛出harness failed to load plugins web boot: 1 entry did not activate nanmicode。三个人用的文档是同一份GitHub README 里写的“支持 Web / Headless / Python SDK 三种接入方式”可实际体验下来像在用三个不同产品。这就是标题直指的核心问题同一套 Agent Runtime为什么 Web、Headless 和 Python SDK 仍然不是同一个产品这个“不是同一个产品”不是指功能缺失而是指三者在启动机制、依赖加载路径、插件激活上下文、错误传播链路、调试可观测性这五个维度上存在系统性、结构性的割裂。它不是 bug而是架构设计中被长期忽略的“隐性契约”——每个形态都默认自己是“主入口”却没人统一定义“Runtime 的最小可运行单元”究竟该包含什么、由谁负责初始化、失败时向谁报告。我做过 7 个跨平台 Agent 项目从金融风控对话引擎到工业设备故障诊断助手踩过所有这三类形态的坑。最典型的一次是客户要求“Web 端能用的功能Python SDK 必须 100% 对齐”我们花了 3 周才搞清Web 端的nanmicode插件是通过 Vite 的import.meta.glob动态加载的而 Python SDK 里硬编码了import nanmicode当用户没装这个包时SDK 不报 ImportError而是静默跳过插件注册导致后续调用直接 failHeadless 模式则更隐蔽——它复用了 Web 的构建产物dist 目录但启动时用的是 Node.js 的require.resolve而 Web 端用的是浏览器的 ESM 解析规则对package.json#exports字段的处理逻辑完全不同一个能 resolve 到./dist/index.cjs另一个却坚持找./src/index.ts于是plugin regis报错里的 “regis” 其实是 “registration” 被截断根源是 Node.js 的 require 缓存机制在非标准路径下触发了异常。所以这篇文章不讲“怎么安装 Python”或“Web 前端开发入门”而是带你一层层剥开 Runtime 的外壳看清楚 Web 的window.runtime、Headless 的process.argv、Python SDK 的sys.path这三套完全不同的“神经系统”是如何让同一段核心逻辑在不同躯体里产生截然不同的行为。如果你正在设计 Agent 平台、维护多形态 SDK或者正被“Web 能跑Python 报错”这类问题折磨这篇就是为你写的实战解剖报告。2. 核心设计拆解Runtime 的“三位一体”幻觉与真实分治2.1 为什么“同一套代码”不等于“同一个产品”很多人误以为只要把核心逻辑写在core/目录下再用 Webpack 打个包、用 PyOxidizer 封个二进制、用 Poetry 构建个 wheel就实现了“一套 Runtime”。这是典型的“编译器思维”——只关注输出物忽略了运行时环境对代码语义的重定义。举个最基础的例子插件注册。假设你的 Runtime 定义了一个标准接口// core/plugin.ts export interface Plugin { name: string; init(runtime: Runtime): Promisevoid; }在 Web 环境中插件通常这样加载// web/src/plugins/index.ts const pluginModules import.meta.glob(./**/*.plugin.ts, { eager: true }); Object.values(pluginModules).forEach((mod) { if (default in mod typeof mod.default function) { const plugin mod.default() as Plugin; runtime.registerPlugin(plugin); } });这段代码依赖两个 Web 特有机制import.meta.globVite 特性和eager: true预加载。把它原封不动塞进 Python SDK 里import.meta在 CPython 里根本不存在。Headless 模式呢它可能用fs.readdirSync扫描plugins/目录再用require()动态加载但require()不支持.ts文件必须先编译成 JS而 Web 端的构建流程又默认把.ts编译进dist/导致 Headless 启动时找不到源文件。提示这里暴露的第一个结构性差异是模块解析策略的不可移植性。Web 用 ESM URL 解析Headless 用 CommonJS 文件路径解析Python SDK 用importlib.util.spec_from_file_location基于.py文件路径解析。三者对“同一个插件路径”的理解从根上就不一致。2.2 三套启动生命周期谁负责初始化谁拥有控制权Runtime 的“活”不是靠代码行数决定的而是靠生命周期钩子的完整性和可控性。我们对比三者的启动流程阶段Web 端Vite ReactHeadlessNode.js CLIPython SDKPyPI 包入口点index.html→main.tsx→createRoot().render()bin/cli.js→program.parseAsync()→handler()from agent_runtime import Agent→Agent(...).run()核心初始化new Runtime({ plugins: [...] })在 React 组件useEffect中执行const runtime new Runtime(); await runtime.init();在 handler 内同步执行runtime Runtime()构造函数内完成大部分初始化插件激活时机useEffect里调用runtime.loadPlugins()依赖 React 渲染时机runtime.init()内部遍历插件目录并await plugin.init(runtime)Runtime.__init__()中尝试import所有插件模块失败则静默跳过错误捕获范围try/catch仅包裹runtime.run()调用插件加载错误常被useEffect的异步机制吞掉try/catch包裹整个handler()插件init()失败会中断整个 CLI 流程try/catch通常只在用户调用.run()时存在构造函数内的插件导入错误直接变成ImportError看到问题了吗Web 端的初始化是“懒加载渲染驱动”Headless 是“启动即加载命令驱动”Python SDK 是“构造即加载调用驱动”。这意味着当你修复了一个插件的init()方法让它能正确连接 RedisWeb 端可能要刷新页面才生效Headless 必须重启进程而 Python SDK 只要重新import就行——但用户不会这么干他们只会pip install --force-reinstall结果发现新版本的 wheel 里setup.py没更新install_requiresRedis 依赖根本没装上。更致命的是错误传播Web 端插件加载失败控制台只打印Failed to load plugin xxx但runtime.status里pluginsLoaded: false这个关键状态前端组件根本没监听Headless 下错误会直接console.error并process.exit(1)Python SDK 却可能在.run()时才第一次访问插件实例此时报错堆栈里已经没有init的上下文你看到的是AttributeError: NoneType object has no attribute do_something而不是真正的根源。2.3 依赖管理的三重迷宫node_modules、site-packages与dist/Runtime 的“血肉”是依赖而三者的依赖管理机制构成了天然的隔离墙。Web 端依赖全部打进dist/目录。Vite 的build.rollupOptions.external配置决定了哪些包不被打包如react,lodash这些外部依赖由 HTML 的script标签提供。但nanmicode这种内部插件如果没配置 external就会被压缩进index.XXX.js体积暴涨配了 external又得手动在index.html里加 script 标签而 Headless 和 Python SDK 根本不认这个。Headless 端依赖全在node_modules/。package.json的dependencies字段是唯一权威。但问题在于Web 端的构建产物dist/里nanmicode的代码是经过 Babel 转译、Tree-shaking 后的 JS而 Headless 直接require(nanmicode)加载的是未经处理的源码.ts或.js两者 API 表面一致内部类型检查、默认导出方式export defaultvsmodule.exports 可能完全不同。Python SDK 端依赖在site-packages/。pyproject.toml的[project.dependencies]是权威。但nanmicode如果是个纯 TypeScript 库Python SDK 就必须提供.pyi类型存根或者用pyodide运行 TS这显然不现实。所以实际方案往往是为 Python 单独写一个nanmicode-py包API 保持兼容但实现是纯 Python。这就引入了语义漂移风险——Web 端的nanmicode支持流式响应Python 版为了简单只做一次性返回用户在 Web 上看到实时打字效果在 Python 里却要等 5 秒才拿到完整结果。注意这种依赖分裂不是技术债而是设计选择。当你把nanmicode的发布流程拆成npm publish、pypi publish、git submodule add三条线时“同一套代码”的幻觉就彻底破灭了。真正的“同一套”应该是一个 monorepo 里用turborepo或nx统一管理构建生成的产物是core-runtime.wasmplugin-nanmicode.wasm三端都加载 WASM 模块——但这需要重构整个生态不是改几行代码的事。3. 核心细节解析从报错日志反推架构真相3.1 拆解error: agent harness runtime codex is unavailable because its plugin regis...这条报错是 Web 和 Headless 端最常见的“拦路虎”表面看是插件注册失败实则是插件发现机制与运行时上下文的错配。我们逐词解析codex这是 Runtime 的名称通常在package.json#name或pyproject.toml#project.name里定义。但它在三端的“身份”不同Web 端它是全局变量window.codexRuntimeHeadless 是const codex new Runtime()的实例名Python SDK 是from codex_runtime import Runtime的模块名。一旦某端的命名空间污染比如 Web 端其他脚本也挂了window.codexRuntime就会覆盖真正的 Runtime 实例。plugin regis...regis是registration的截断说明错误发生在pluginRegistration阶段。但关键不在“注册”而在“发现”。查看源码你会发现runtime.loadPlugins()的核心逻辑是// core/runtime.ts async loadPlugins() { const pluginPaths await this.pluginLocator.locate(); // 关键插件定位器 for (const path of pluginPaths) { const pluginModule await import(path); // 关键动态导入 const plugin pluginModule.default?.(); if (plugin) await this.registerPlugin(plugin); } }pluginLocator.locate()的实现才是三端差异的根源Web 端locate()返回[/plugins/nanmicode.plugin.js]路径是相对于public/的 URL。它依赖fetch(/plugins/nanmicode.plugin.js)成功而这个 URL 必须由 Web 服务器Vite Dev Server 或 Nginx正确路由到dist/plugins/目录。如果用户用file://协议打开 HTMLfetch会因 CORS 失败报错却显示为plugin regis...。Headless 端locate()返回[./plugins/nanmicode.plugin.js]路径是相对于process.cwd()的文件系统路径。它依赖fs.existsSync()和require.resolve()。但如果用户在/home/user/myapp下运行npx codex-runtime --plugin-dir ../shared-pluginsrequire.resolve()就会去../shared-plugins/node_modules/里找而../shared-plugins可能根本没有package.json导致require.resolve抛出Cannot find module错误被catch后只打印前半句。Python SDK 端locate()根本不走这个逻辑它用的是pkg_resources.iter_entry_points(agent_runtime.plugins)依赖setup.py里entry_points的声明。如果nanmicode-py的setup.py没写entry_points{agent_runtime.plugins: [nanmicode nanmicode.plugin:plugin_instance]}iter_entry_points就返回空列表loadPlugins()循环一次都不执行runtime.plugins为空后续调用.run()时任何需要插件的功能都会undefined is not a function但错误堆栈里绝不会出现plugin regis。3.2dsh web authentication required; reopen the url printed by dsh web.的背后Web 会话的脆弱性这条提示看似是认证问题实则是Web 端 Runtime 与后端服务的会话绑定机制失效。dsh可能是dev-server-helper的缩写在启动时会启动一个本地 HTTP 服务如http://localhost:8080生成一个临时 JWT Token存入内存打印Visit http://localhost:8080?tokenxxx到控制台Web 前端在main.ts里读取 URL 参数token并存入localStorage问题出在第 4 步localStorage是同源策略下的隔离存储。如果用户复制链接用 Chrome 打开再用 Safari 打开两个浏览器的localStorage互不相通。更常见的是用户关掉终端dsh进程退出内存里的 token 彻底丢失但http://localhost:8080这个地址还在前端 JS 试图用旧 token 请求/api/health后端校验失败返回401 Unauthorized前端 UI 却没做兜底直接卡死只留下这句冰冷的提示。而 Headless 和 Python SDK 完全没有这个问题——它们用的是--api-key命令行参数或API_KEY环境变量token 存在进程内存里生命周期与进程一致。Web 端的“无状态”特性在开发期成了最大的状态包袱。3.3your last request has been blocked for security purposes. please contact web安全策略的跨端失焦这个报错通常出现在 Web 端调用 Runtime 的executeAction()方法时。它不是 Runtime 自己抛的而是被Web 浏览器的安全策略拦截。例如用户在 Web 页面里用 Runtime 调用了一个shell.exec(rm -rf /)的插件。Chrome 的Content Security Policy (CSP)检测到unsafe-eval或unsafe-inline直接阻止执行并返回这个泛化错误。或者Runtime 尝试用fetch请求一个非 HTTPS 的后端 API如http://localhost:3000/llm现代浏览器强制升级为 HTTPS请求被net::ERR_INSECURE_RESPONSE中断前端 JS 捕获到TypeError: Failed to fetch但错误处理逻辑里只写了if (err.name TypeError) console.log(security blocked)于是显示这句提示。Headless 和 Python SDK 不受 CSP 限制它们的exec或requests.post会真实发出网络请求错误是具体的ConnectionRefusedError或requests.exceptions.ConnectionError。这种安全边界的位置偏移导致三端的错误日志完全无法对齐——Web 端看到的是浏览器层面的“安全拦截”后端看到的是 403 Forbidden而 Python SDK 里连请求都没发出去因为os.system()被subprocess模块的checkTrue参数提前拒绝了。4. 实操过程如何让三端真正“同源同构”4.1 统一插件模型从“动态加载”到“静态契约”解决plugin regis类错误的根本是放弃“运行时发现插件”的幻想转向“编译时声明契约”。我们设计一个PluginManifest// plugins/manifest.json { version: 1.0, plugins: [ { name: nanmicode, type: web|headless|python, entry: ./nanmicode/dist/index.js, dependencies: [redis, openai], capabilities: [streaming, caching] } ] }三端都读取这个 JSON但加载方式不同Web 端fetch(/plugins/manifest.json)→ 解析entry→import(entry)。不再用glob避免路径歧义。Headless 端fs.readFileSync(./plugins/manifest.json)→ 解析entry→require(resolve(entry))。resolve用path.resolve(__dirname, entry)确保路径绝对。Python SDK 端import json; manifest json.load(open(plugins/manifest.json))→ 对每个type: python的插件执行importlib.import_module(manifest[entry])。entry是 Python 模块路径如nanmicode.plugin。这样插件的“存在性”由manifest.json唯一定义三端只是用各自的方式“兑现”这个契约。plugin regis错误就变成了清晰的PluginNotFoundError: nanmicode not found in manifest.json。4.2 统一错误中心用结构化事件替代字符串日志把所有错误包装成RuntimeErrorEvent// core/error.ts export interface RuntimeErrorEvent { code: string; // PLUGIN_LOAD_FAILED, AUTH_TOKEN_EXPIRED message: string; context: { pluginName?: string; runtimeMode: web | headless | python; timestamp: number; }; cause?: Error; } // Web 端广播 window.dispatchEvent(new CustomEvent(runtime:error, { detail: errorEvent })); // Headless 端 process.send?.({ type: runtime:error, payload: errorEvent }); // Python SDK 端 import logging logger logging.getLogger(agent_runtime) logger.error(Runtime error, extra{event: errorEvent})用户可以在任意一端监听runtime:error事件拿到结构化数据。前端可以据此显示友好的错误卡片Headless 可以生成带--debug的详细堆栈Python SDK 可以把event.context写入 Sentry 的extra字段。再也不用从plugin regis...这种截断字符串里猜原因。4.3 统一认证流Token 代理模式废除dsh web的临时 token改用Token 代理网关启动一个轻量级代理服务如用express写的auth-proxy.js监听http://localhost:9000Web 前端所有 Runtime 请求都发给http://localhost:9000/api/...代理服务检查Authorization: Bearer token验证通过后转发给真正的 Runtime 后端如http://localhost:3000并注入X-Forwarded-For等头Headless 和 Python SDK 直连http://localhost:3000用--api-key参数这样Web 端的认证完全交给代理不再依赖localStorage和 URL 参数。用户只需在代理服务启动后访问http://localhost:9000代理会自动重定向到登录页登录成功后所有 Web 请求都携带有效 token。dsh web authentication required的提示就变成了Visit http://localhost:9000 to login清晰明了。4.4 统一构建流水线Turborepo Cross-Platform Artifacts用 Turborepo 管理 monorepomy-agent-runtime/ ├── packages/ │ ├── core/ # TypeScript 核心逻辑无框架依赖 │ ├── web/ # Vite React依赖 core │ ├── headless/ # Node.js CLI依赖 core │ └── python-sdk/ # pyproject.toml依赖 core通过 npm pack pip install ├── plugins/ │ └── nanmicode/ # 独立包build 后产出 dist/ 和 py/ 目录 └── turbo.jsonturbo.json定义构建顺序{ pipeline: { build: { dependsOn: [^build], outputs: [dist/**, py/**] }, web#build: { dependsOn: [core#build, nanmicode#build] }, headless#build: { dependsOn: [core#build, nanmicode#build] }, python-sdk#build: { dependsOn: [core#build, nanmicode#build] } } }每次turbo run buildnanmicode的构建脚本会运行tsc生成dist/供 Web/Headless 用运行pyright生成py/供 Python SDK 用含.pyi存根打包nanmicode-py-1.0.0.tar.gz到python-sdk/dist/Python SDK 的pyproject.toml不再写死nanmicode-py而是[project.optional-dependencies] nanmicode [nanmicode-py file://./dist/nanmicode-py-1.0.0.tar.gz]用户pip install -e .[nanmicode]就能确保安装的nanmicode-py与当前 Web/Headless 使用的nanmicode是同一 commit 的产物。这才是“同一套代码”的物理基础。5. 常见问题与排查技巧实录5.1 Web 端能跑Python SDK 报ModuleNotFoundError三步定位法这是最高频问题。不要急着pip install按顺序检查查 manifest.json 是否包含该插件运行python -c import json; print(json.load(open(plugins/manifest.json))[plugins])确认nanmicode在列表中且type: python。查 Python SDK 是否启用了该插件在 Python 代码里加一行from agent_runtime import Runtime print(Available plugins:, Runtime().list_available_plugins())如果输出为空说明manifest.json没被正确加载检查agent_runtime/__init__.py里plugins_dir的路径是否指向正确的plugins/目录。查插件模块是否可 import运行python -c import nanmicode.plugin; print(nanmicode.plugin.plugin_instance)。如果报ModuleNotFoundError说明nanmicode-py没装或者sys.path里没有它的路径。此时执行pip install -e ./plugins/nanmicode/py/假设nanmicode/py/是它的 Python 源码目录。实操心得我曾经在一个项目里nanmicode-py的setup.py里packagesfind_packages()没包含nanmicode.plugin子模块导致pip install后import nanmicode.plugin失败。find_packages()默认不递归扫描子目录必须显式写find_packages(include[nanmicode*])。这种细节只有在python -c import ...时才能暴露。5.2 Headless 模式下harness failed to load plugins web boot: 1 entry did not activate路径解析陷阱这个错误的关键词是web boot说明 Headless 端错误地加载了 Web 端的启动逻辑。原因通常是package.json的exports字段配置不当// nanmicode/package.json { exports: { .: { import: ./dist/index.js, require: ./dist/index.cjs, browser: ./dist/web-boot.js // ❌ 错误Headless 也走了 browser 字段 } } }Node.js 的require()在某些版本下会优先读取browser字段导致 Headless 加载了为 Web 优化的web-boot.js里面包含window对象引用自然报错。解决方案是删除browser字段或改为exports: { .: { import: ./dist/index.js, require: ./dist/index.cjs }, ./web: { import: ./dist/web-boot.js } }然后 Headless 端明确require(nanmicode/web)Web 端import nanmicode。用字段隔离而非让 Node.js 猜。5.3 Python SDK 的AttributeError: NoneType object has no attribute xxx静默失败的代价这个错误几乎总是因为插件init()失败后runtime.plugins[nanmicode]是None但后续代码没做空值检查。根本解法是在 Runtime 构造函数里强制校验# python-sdk/agent_runtime/runtime.py def __init__(self, plugins_configNone): self.plugins {} if plugins_config: for name, config in plugins_config.items(): try: plugin self._load_plugin(name, config) self.plugins[name] plugin except Exception as e: # 不再静默记录错误并 raise logger.error(fFailed to load plugin {name}, exc_infoe) raise RuntimeError(fPlugin {name} failed to load: {e})这样AttributeError就会变成清晰的RuntimeError: Plugin nanmicode failed to load: ImportError: No module named redis用户立刻知道要pip install redis。5.4 Web 端Network Unavailable但 Headless 正常CORS 与协议陷阱当 Web 控制台显示Network Unavailable而curl http://localhost:3000/health返回200 OK99% 是 CORS 问题。但别急着加Access-Control-Allow-Origin: *先确认协议是否一致Web 页面是https://myapp.com但 Runtime 后端是http://localhost:3000浏览器会阻止混合内容。解决方案Web 页面也用http://localhost:5173Vite 默认或后端启用 HTTPS。端口是否被占用localhost:3000被其他程序占用了lsof -i :3000查看。代理配置是否生效Vite 的vite.config.ts里server.proxy是否配置了server: { proxy: { /api: { target: http://localhost:3000, changeOrigin: true, } } }然后 Web 前端请求/api/health而不是http://localhost:3000/health。注意changeOrigin: true是关键它会修改请求头的Origin让后端的 CORS 中间件认为这是合法跨域。很多新手只配target忘了changeOrigin结果还是 403。5.5 三端行为不一致的终极排查表当用户说“Web 上 A 功能正常Python SDK 里 A 功能返回空”用此表快速定位检查项Web 端Headless 端Python SDK 端如何验证Runtime 版本console.log(codexRuntime.version)npx codex-runtime --versionpython -c import agent_runtime; print(agent_runtime.__version__)三者必须完全一致如1.2.3插件版本console.log(codexRuntime.plugins[nanmicode].version)npx codex-runtime list-pluginspython -c from nanmicode.plugin import plugin_instance; print(plugin_instance.version)插件版本号需对齐网络请求 URLChrome DevTools → Network → 点击请求 → Headers →Request URLnpx codex-runtime --debug run ...查看 debug 日志中的 URLimport logging; logging.basicConfig(levellogging.DEBUG)看 requests 库的 DEBUG 日志确认三端请求的是同一个 endpoint请求头Network → Headers →Request Headers--headers {Authorization: Bearer xxx}headers{Authorization: Bearer xxx}Authorization、Content-Type 必须一致请求体Network → Payload--input {prompt:hi}input{prompt:hi}用JSON.stringify()格式化对比注意单双引号、空格这张表是我压箱底的排查武器。它不假设你懂底层原理只问“你看到的值是什么”用事实说话绕过所有“应该”和“可能”。6. 我的实战体会接受“分治”拥抱“契约”写完这篇我重新翻了自己最早做的那个 Agent 项目——当时天真地以为只要把core/目录写好Web、CLI、Python 就是“自然而然”的延伸。结果上线第一周客服收到 23 条“Web 能用Python 报错”的反馈每一条都要花 2 小时定位最后发现是nanmicode的package.json里main字段指向了未编译的src/index.ts而 Python SDK 的import机制恰好能容忍.ts文件因为用了ts-node但 Web 端的 Vite 构建却失败了导致dist/里没有index.jsimport.meta.glob找不到文件报plugin regis...。后来我明白了“同一套 Runtime” 不是目标而是手段真正的目标是让三端用户获得一致的、可预测的行为。这种一致性不能靠“共享代码”来保证而要靠“共享契约”——Manifest 文件、结构化错误、统一的构建流水线、清晰的版本对齐规则。代码可以不同但契约必须铁律。所以现在我的新项目第一天就写plugins/manifest.json模板第二天就搭 Turborepo 流水线第三天就写三端的list-plugins命令。我不再追求“一份代码跑三端”的技术浪漫而是务实地理清Web 端负责交互Headless 负责自动化Python SDK 负责集成。它们像三个不同语言的翻译官共同服务于同一个 Runtime 核心。翻译可能有细微差别但意思绝不能错。最后分享一个小技巧在README.md里用表格明确标注每个功能的“三端支持度”例如功能Web 端Headless 端Python SDK 端备注流式响应✅✅⚠️ 仅支持完整返回streamFalse为默认插件热重载✅❌ 需重启❌ 需重新 importWeb 端通过 HMR 实现离线缓存✅✅✅均使用 IndexedDB / LevelDB用户一眼就知道边界在哪你的支持工作量会因此减少 70%。
返回列表