ARTICLE DETAIL

资讯详情

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

Agent系统底层设计:存储后端、沙盒隔离与MCP协议实战

Agent系统底层设计:存储后端、沙盒隔离与MCP协议实战 1. 从存储后端到沙盒隔离一套Agent系统的底层设计逻辑1.1 为什么存储后端的选择决定了整个系统的上限做Agent系统的人迟早会撞上一堵墙会话状态到底往哪儿放。我见过太多项目一开始图省事把对话历史、工具调用记录、中间产物全塞进内存字典里跑单轮Demo没问题一旦涉及多轮长会话或者并发请求内存直接爆炸进程重启后上下文全丢用户回来发现Agent失忆了。存储后端的选择不是简单的“用Redis还是用SQLite”的问题它牵扯到三个核心维度持久化粒度、读写延迟、状态一致性。我拿实际项目举例早期我用纯内存做会话管理单机跑得好好的后来加了定时任务触发Agent自动执行问题就来了——定时任务和用户请求可能同时操作同一个会话内存里的字典没有事务保护写冲突导致上下文错乱。后来换成SQLite做持久化每个会话一个独立的row用WAL模式开启并发读写入用事务包起来问题才解决。但SQLite也不是银弹。当会话数量上到几千个、每个会话有上百条消息记录时单文件数据库的写入锁开始成为瓶颈。这时候就要考虑分层存储热数据放内存缓存温数据放SQLite或Redis冷数据归档到对象存储。我目前的方案是会话元信息会话ID、创建时间、最后活跃时间放Redis消息体按会话ID分片存SQLite超过30天不活跃的会话自动导出到本地文件系统归档。这样单机可以扛住几千个活跃会话查询延迟控制在10ms以内。注意存储后端切换时一定要做数据迁移的幂等设计。我踩过一次坑从内存切SQLite时没做去重导致部分会话的消息被重复写入Agent回复时引用了两条一样的上下文输出变得极其啰嗦。1.2 沙盒隔离不是可选项是生存底线Agent系统跟普通Web应用最大的区别在于它会执行代码。不管是执行用户提供的Python脚本、调用系统命令、还是操作文件只要涉及执行就必须有沙盒。我见过有人直接把Agent生成的代码用exec()跑在主进程里结果Agent写了个死循环把整个服务卡死更严重的案例是Agent生成的代码删除了项目目录下的文件。沙盒隔离的核心目标是三件事资源限制、文件系统隔离、网络访问控制。资源限制包括CPU时间、内存上限、进程数量文件系统隔离确保Agent只能看到指定的工作目录网络访问控制决定Agent能不能发起外部请求。我目前用的方案是子进程资源限制chroot式目录隔离。具体做法是Agent需要执行代码时fork一个子进程在子进程里用resource模块设置CPU时间和内存上限用os.chroot()把根目录切到临时工作区再用setrlimit限制文件描述符数量。这样即使Agent生成的代码有恶意行为影响范围也被限制在临时目录里主进程和其他会话不受影响。import resource import os import subprocess def run_in_sandbox(code: str, work_dir: str, timeout: int 10): def preexec(): # 限制CPU时间为timeout秒 resource.setrlimit(resource.RLIMIT_CPU, (timeout, timeout)) # 限制内存为256MB resource.setrlimit(resource.RLIMIT_AS, (256 * 1024 * 1024, 256 * 1024 * 1024)) # 限制进程数为1 resource.setrlimit(resource.RLIMIT_NPROC, (1, 1)) # 切换工作目录 os.chdir(work_dir) result subprocess.run( [python3, -c, code], preexec_fnpreexec, capture_outputTrue, timeouttimeout 2, textTrue ) return result.stdout, result.stderr这段代码的关键在于preexec_fn它在子进程exec之前执行设置各种资源限制。RLIMIT_CPU是硬限制超过直接发SIGKILLRLIMIT_AS限制虚拟内存防止内存炸弹RLIMIT_NPROC防止fork炸弹。实测下来这套限制能挡住99%的意外情况。但沙盒不是万能的。Python的resource模块在macOS上部分限制不生效Windows上更是完全另一套机制。跨平台方案要么用Docker容器做隔离要么用firejail这类工具。我个人的选择是开发环境用子进程resource生产环境用Docker每个代码执行请求起一个临时容器执行完立即销毁。1.3 MCP协议Agent与工具之间的“USB接口”MCP最近火得不行各种“xxx MCP”层出不穷。但很多人没搞明白MCP到底解决什么问题。我用一句话概括MCP是Agent和工具之间的标准化通信协议就像USB接口让不同设备都能插到电脑上一样MCP让不同工具都能被Agent调用。在没有MCP之前每接一个工具就要写一套适配代码。接Playwright要写浏览器操作的封装接数据库要写SQL执行的封装接文件系统要写读写封装。工具一多适配代码比业务逻辑还多。MCP的出现把这件事标准化了工具方实现一个MCP Server暴露标准的接口描述Agent方实现一个MCP Client按照标准协议调用。双方不需要知道对方的具体实现只要遵循协议就能通信。MCP的核心概念有三个Resources、Tools、Prompts。Resources是Agent可以读取的数据源比如文件内容、数据库记录Tools是Agent可以调用的操作比如执行命令、发送请求Prompts是预定义的提示模板帮助Agent更好地使用工具。我实际用下来Tools是最常用的Resources次之Prompts用得最少。MCP的通信方式支持stdio和SSE两种。stdio适合本地工具Agent直接启动工具进程通过标准输入输出通信SSE适合远程工具通过HTTP长连接通信。我目前本地开发用stdio部署到服务器时用SSE这样工具可以独立部署和扩缩容。提示MCP Server的日志管理是个容易被忽视的坑。默认情况下MCP Server的日志会混在stdio里导致Agent把日志当成工具返回结果。我的做法是让MCP Server把日志写到stderrstdout只输出协议数据这样Agent就不会误读。1.4 核心设计哲学为什么“简单”比“强大”更难做到做了这么多Agent系统我最大的体会是核心设计哲学决定了系统能走多远。很多项目一开始追求功能大而全什么工具都接什么场景都支持结果代码复杂度爆炸维护成本极高最后没人敢改。我的设计哲学可以总结为三条最小接口原则、显式状态管理、失败可恢复。最小接口原则是指Agent和工具之间的接口尽可能少。MCP的Tools接口就一个call_tool方法传工具名和参数返回结果。不需要为每个工具设计专门的接口减少认知负担。显式状态管理是指所有状态都明确存储和传递不依赖隐式的全局变量或闭包。会话状态存在存储后端里工具调用状态存在调用记录里Agent的每一步操作都有迹可循。这样出问题时可以回溯可以重放可以调试。失败可恢复是指任何一步失败都不会导致整个系统崩溃。工具调用超时了Agent可以重试或换工具存储后端挂了Agent可以降级到内存模式沙盒执行失败了Agent可以返回错误信息让用户决定下一步。我见过太多系统因为一个工具调用失败就整个会话卡死用户体验极差。这三条哲学看起来简单但实际落地时需要大量细节设计。比如最小接口原则要求工具的参数和返回值都是JSON可序列化的不能传函数或对象显式状态管理要求每个状态变更都要写存储不能只在内存里改失败可恢复要求每个外部调用都有超时和重试机制。这些细节才是真正区分“能跑”和“好用”的关键。2. 存储后端的选型对比与落地实操2.1 四种存储方案的实测对比存储后端的选择没有绝对的好坏只有适不适合。我把用过的四种方案拉出来做个对比都是实际项目跑过的数据。方案读写延迟持久化并发能力适用场景坑点纯内存字典1ms无差需加锁单轮Demo、测试重启丢数据并发写冲突SQLite1-5ms有中等WAL模式单机中小规模写入锁瓶颈大文件性能下降Redis1ms可配置强高并发、分布式内存成本高持久化有丢数据风险PostgreSQL5-20ms有强大规模、复杂查询运维复杂连接池管理麻烦我目前的项目用的是SQLiteRedis混合方案。会话元信息谁、什么时候、活跃状态放Redis因为需要频繁更新和快速查询消息体放SQLite因为消息量大、查询模式简单、不需要频繁更新。这个组合在单机上跑几千个活跃会话没问题延迟稳定在5ms以内。选SQLite而不是PostgreSQL的原因是运维成本。SQLite零配置一个文件就是全部数据备份就是复制文件迁移就是拷贝文件。PostgreSQL虽然功能强大但需要独立进程、连接池、定期vacuum对于中小规模Agent系统来说太重了。等会话量上到十万级再考虑迁移。2.2 SQLite的WAL模式与并发写入优化SQLite默认的journal模式在写入时会锁整个数据库读操作被阻塞。开启WALWrite-Ahead Logging模式后读写可以并发写入不阻塞读读也不阻塞写。开启方法很简单PRAGMA journal_modeWAL; PRAGMA synchronousNORMAL; PRAGMA busy_timeout5000;journal_modeWAL开启预写日志synchronousNORMAL在WAL模式下兼顾性能和安全比FULL快很多比OFF安全busy_timeout5000设置写入锁等待超时避免立即返回SQLITE_BUSY错误。但WAL模式也有代价会生成-wal和-shm两个额外文件备份时需要一起复制WAL文件会不断增长需要定期checkpoint。我设置的是每1000次写入或每5分钟自动checkpoint一次PRAGMA wal_autocheckpoint1000;实测下来WAL模式下SQLite可以支撑每秒几百次的写入对于Agent系统来说完全够用。如果写入量更大就要考虑分库分表或者换Redis了。2.3 消息表的Schema设计与索引策略消息表的Schema设计直接影响查询性能。我踩过的坑是一开始用单表存所有消息字段有session_id、role、content、timestamp、metadata查询时用WHERE session_id ? ORDER BY timestamp。数据量小的时候没问题上到百万行后查询明显变慢。优化方案是按session_id分表。每个会话一个独立的表表名是messages_{session_id}。这样查询时直接定位到具体表不需要全表扫描。但分表也有问题会话数量多时表数量爆炸SQLite对表数量有限制默认2000张。所以我的做法是按时间分片每个月一个数据库文件每个文件里按session_id分表。查询时先定位到月份再定位到表。索引方面session_id和timestamp的联合索引是必须的CREATE INDEX idx_session_time ON messages (session_id, timestamp);这个索引让WHERE session_id ? ORDER BY timestamp的查询走索引扫描不需要排序。另外如果经常按角色查询可以加role的单列索引但索引不是越多越好每个索引都会增加写入开销。我目前只保留两个索引联合索引和主键索引。2.4 存储后端的容灾与数据迁移存储后端最怕的是数据丢失。我的容灾策略是三层备份实时备份到Redis热备每小时备份到本地文件温备每天备份到外部存储冷备。热备用于快速恢复温备用于误删恢复冷备用于灾难恢复。数据迁移是另一个容易出问题的环节。从内存迁到SQLite时我写了一个迁移脚本逐条读取内存中的会话写入SQLite写入前先检查是否已存在用会话ID时间戳做唯一键存在则跳过。这样脚本可以重复执行不会产生重复数据。从SQLite迁到PostgreSQL时我用的是pgloader工具它支持从SQLite直接导入PostgreSQL自动处理类型转换和索引重建。但要注意SQLite的TEXT类型在PostgreSQL里最好转成JSONB或VARCHARINTEGER主键要转成SERIAL或BIGSERIAL。迁移前先在测试环境跑一遍确认数据完整性和查询性能。注意迁移过程中一定要停写。我试过不停写迁移结果迁移期间的新数据丢失了。正确做法是先停写迁移存量数据切换读写到新存储再恢复写入。整个过程控制在分钟级对用户影响最小。3. 沙盒隔离的完整实现与安全边界3.1 子进程隔离的完整代码实现沙盒隔离的核心是子进程资源限制目录隔离。我把完整实现拆开讲每一部分都有讲究。import os import sys import resource import subprocess import tempfile import shutil class Sandbox: def __init__(self, timeout10, memory_mb256, max_output1024*1024): self.timeout timeout self.memory_bytes memory_mb * 1024 * 1024 self.max_output max_output def execute(self, code: str, language: str python) - dict: # 创建临时工作目录 work_dir tempfile.mkdtemp(prefixsandbox_) try: # 写入代码文件 code_file os.path.join(work_dir, main.py) with open(code_file, w) as f: f.write(code) # 设置资源限制 def preexec(): resource.setrlimit(resource.RLIMIT_CPU, (self.timeout, self.timeout)) resource.setrlimit(resource.RLIMIT_AS, (self.memory_bytes, self.memory_bytes)) resource.setrlimit(resource.RLIMIT_NPROC, (1, 1)) resource.setrlimit(resource.RLIMIT_FSIZE, (self.max_output, self.max_output)) os.chdir(work_dir) os.setsid() # 创建新会话防止信号干扰 # 执行 result subprocess.run( [sys.executable, code_file], preexec_fnpreexec, capture_outputTrue, timeoutself.timeout 2, textTrue, cwdwork_dir ) return { stdout: result.stdout[:self.max_output], stderr: result.stderr[:self.max_output], returncode: result.returncode } except subprocess.TimeoutExpired: return {stdout: , stderr: Execution timeout, returncode: -1} finally: shutil.rmtree(work_dir, ignore_errorsTrue)这段代码有几个关键点。os.setsid()创建新的会话组这样超时后可以杀掉整个进程组防止子进程fork出孙进程逃逸。RLIMIT_FSIZE限制文件写入大小防止Agent写满磁盘。shutil.rmtree在finally里执行确保临时目录一定被清理。3.2 文件系统隔离的三种方案对比文件系统隔离决定了Agent能看到哪些文件。我试过三种方案各有优劣。方案一chroot。用os.chroot()把根目录切到临时工作区。优点是彻底Agent完全看不到工作区外的文件。缺点是需要root权限而且chroot后Python解释器可能找不到标准库需要把Python环境也复制到工作区里。方案二bind mount。用mount --bind把工作区挂载到指定目录Agent只能访问这个目录。优点是不需要复制环境缺点是同样需要root权限而且mount操作在容器里可能受限。方案三路径白名单。不改变文件系统结构而是在Agent执行代码前用AST分析或运行时hook检查所有文件操作只允许访问白名单内的路径。优点是无需特殊权限缺点是可能被绕过比如用os.open直接调系统调用。我目前用的是方案三方案一的混合开发环境用路径白名单简单够用生产环境用chroot彻底隔离。路径白名单的实现是在子进程里注入一个sitecustomize.pyhookopen、os.open、os.listdir等函数检查路径是否在白名单内。import os import builtins ALLOWED_PREFIX /tmp/sandbox_ _original_open builtins.open def guarded_open(file, *args, **kwargs): path os.path.abspath(str(file)) if not path.startswith(ALLOWED_PREFIX): raise PermissionError(fAccess denied: {path}) return _original_open(file, *args, **kwargs) builtins.open guarded_open这个hook能挡住大部分文件访问但挡不住os.open、subprocess调用的外部命令。所以生产环境还是得用chroot或容器。3.3 网络访问控制的实现细节Agent执行代码时能不能访问网络这是个需要仔细考虑的问题。完全禁止网络会限制Agent的能力比如调用API完全放开又可能导致数据泄露或滥用。我的策略是默认禁止按需开放。默认情况下子进程里设置环境变量http_proxy和https_proxy指向一个不存在的地址这样所有HTTP请求都会失败。如果某个工具确实需要网络在启动子进程时传入允许的域名列表用一个本地代理做白名单转发。def preexec_with_network(allowed_domains): def preexec(): # 设置代理为本地白名单代理 os.environ[http_proxy] http://127.0.0.1:8888 os.environ[https_proxy] http://127.0.0.1:8888 os.environ[no_proxy] ,.join(allowed_domains) # ... 其他资源限制 return preexec本地代理用mitmproxy或自己写一个简单的转发服务检查请求的Host是否在白名单内不在则返回403。这样Agent只能访问允许的域名其他请求全部被拦截。提示网络控制一定要在子进程层面做不要在主进程做。我试过在主进程hook socket结果子进程直接调系统调用绕过了。子进程层面的环境变量代理是最可靠的方案。3.4 沙盒逃逸的常见手法与防御做沙盒的人一定要知道常见的逃逸手法才能针对性防御。我整理了几种常见的fork炸弹Agent写一个无限fork的代码耗尽系统进程数。防御RLIMIT_NPROC限制进程数。内存炸弹Agent申请大量内存导致OOM。防御RLIMIT_AS限制虚拟内存。磁盘炸弹Agent写大量文件占满磁盘。防御RLIMIT_FSIZE限制单文件大小配合磁盘配额。信号逃逸Agent发送信号给父进程或其他进程。防御os.setsid()创建新会话隔离信号。文件描述符泄漏Agent打开大量文件不关闭。防御RLIMIT_NOFILE限制文件描述符数量。时间逃逸Agent修改系统时间或设置定时任务。防御沙盒内禁止settimeofday和cron相关操作。这些防御措施组合起来能挡住绝大多数意外和恶意行为。但安全是个持续对抗的过程没有一劳永逸的方案。我的做法是定期审计沙盒日志发现异常行为及时调整策略。4. MCP协议的核心机制与实战接入4.1 MCP的通信模型stdio与SSE的取舍MCP支持两种通信方式stdio和SSE。stdio是标准输入输出Agent启动MCP Server进程通过管道发送JSON-RPC消息SSE是Server-Sent EventsAgent通过HTTP连接MCP Server服务端推送消息。stdio的优点是简单、低延迟、无需网络配置。缺点是Server和Agent必须在同一台机器上Server崩溃会影响Agent。我本地开发用stdio启动快调试方便。SSE的优点是Server可以独立部署、独立扩缩容、独立升级。缺点是延迟稍高需要处理网络异常和重连。我生产环境用SSEMCP Server部署在独立的容器里Agent通过内网地址连接。两种方式的协议消息格式是一样的都是JSON-RPC 2.0。区别只在于传输层。所以MCP Server可以同时支持两种方式根据启动参数决定用哪种。# stdio模式 async def run_stdio(): async with stdio_server() as (read_stream, write_stream): await server.run(read_stream, write_stream, init_options) # SSE模式 async def run_sse(host0.0.0.0, port8080): await server.run_sse(host, port)4.2 实现一个最小可用的MCP Server我拿一个文件读写工具举例实现一个最小可用的MCP Server。这个Server暴露两个工具read_file和write_file。from mcp.server import Server from mcp.server.stdio import stdio_server from mcp.types import Tool, TextContent import asyncio import os app Server(file-tools) app.list_tools() async def list_tools(): return [ Tool( nameread_file, description读取指定路径的文件内容, inputSchema{ type: object, properties: { path: {type: string, description: 文件路径} }, required: [path] } ), Tool( namewrite_file, description写入内容到指定文件, inputSchema{ type: object, properties: { path: {type: string}, content: {type: string} }, required: [path, content] } ) ] app.call_tool() async def call_tool(name: str, arguments: dict): if name read_file: path arguments[path] if not os.path.exists(path): return [TextContent(typetext, textf文件不存在: {path})] with open(path, r) as f: content f.read() return [TextContent(typetext, textcontent)] elif name write_file: path arguments[path] content arguments[content] os.makedirs(os.path.dirname(path), exist_okTrue) with open(path, w) as f: f.write(content) return [TextContent(typetext, textf写入成功: {path})] else: return [TextContent(typetext, textf未知工具: {name})] async def main(): async with stdio_server() as (read_stream, write_stream): await app.run(read_stream, write_stream, app.create_initialization_options()) if __name__ __main__: asyncio.run(main())这个Server的核心是app.list_tools()和app.call_tool()两个装饰器。前者返回工具列表和参数Schema后者根据工具名分发调用。参数Schema用JSON Schema描述Agent根据Schema生成调用参数。4.3 MCP Client的接入与工具发现Agent侧需要实现MCP Client连接MCP Server发现工具调用工具。我用Python的mcp库举例from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client async def connect_mcp_server(command: str, args: list): server_params StdioServerParameters(commandcommand, argsargs) async with stdio_client(server_params) as (read, write): async with ClientSession(read, write) as session: await session.initialize() # 发现工具 tools await session.list_tools() print(f发现 {len(tools.tools)} 个工具) for tool in tools.tools: print(f - {tool.name}: {tool.description}) # 调用工具 result await session.call_tool(read_file, {path: /tmp/test.txt}) print(result.content[0].text)Client的核心是initialize()握手、list_tools()发现工具、call_tool()调用工具。握手时会交换协议版本和能力信息确保双方兼容。工具发现是MCP的一大优势。Agent不需要硬编码工具列表启动时动态发现工具增减对Agent透明。我目前接入了文件读写、命令执行、HTTP请求、数据库查询四个MCP ServerAgent启动时自动发现所有工具根据用户请求选择合适的工具调用。4.4 MCP Server的日志管理与调试技巧MCP Server的日志管理是个大坑。默认情况下Python的logging输出到stderr但stdio模式下stderr和stdout是分开的Agent只读stdout所以日志不会干扰协议数据。但如果Server里有人用print()输出调试信息就会混入stdout导致Agent解析失败。我的做法是所有日志走stderrstdout只输出协议数据。在Server启动时重定向sys.stdout到一个缓冲区只有协议库写入时才放行。import sys import logging # 配置日志到stderr logging.basicConfig( levellogging.INFO, format%(asctime)s [%(levelname)s] %(message)s, streamsys.stderr ) # 保护stdout class ProtocolStdout: def __init__(self, original): self.original original def write(self, data): self.original.write(data) def flush(self): self.original.flush() sys.stdout ProtocolStdout(sys.stdout)调试MCP Server时我建议先用mcp命令行工具手动测试echo {jsonrpc:2.0,id:1,method:tools/list,params:{}} | python server.py这样可以直接看到Server的响应确认协议格式正确。另外可以用mcp-inspector工具可视化调试它提供了一个Web界面可以查看工具列表、调用工具、查看日志。提示MCP Server的版本兼容性要注意。不同版本的MCP协议可能有差异Client和Server的协议版本要匹配。我在initialize时检查协议版本不匹配则拒绝连接并提示升级。5. 核心设计哲学的落地与常见问题排查5.1 最小接口原则的实践为什么工具越少越好最小接口原则听起来反直觉——工具不是越多越好吗我的经验恰恰相反工具越多Agent越难选对。我试过一个项目接了20多个工具结果Agent经常选错工具或者把多个工具串起来用导致逻辑混乱。后来砍到5个核心工具准确率反而提升了。原因是Agent的选择空间越大决策难度越高。每个工具都有描述和参数SchemaAgent需要理解每个工具的用途判断当前场景该用哪个。工具多了描述之间的边界模糊Agent就容易混淆。我的做法是合并同类工具。比如read_file、read_json、read_csv合并成一个read_file根据文件扩展名自动选择解析方式。write_file、append_file、delete_file合并成一个write_file用mode参数区分操作类型。这样工具数量减少但功能不减少。另一个做法是分层工具。核心工具直接暴露给Agent扩展工具通过一个execute_tool工具间接调用。Agent先选核心工具需要扩展时再通过execute_tool指定具体工具名。这样Agent的决策空间小了但能力没受限。5.2 显式状态管理的实现状态机与事件溯源显式状态管理的核心是所有状态变更都记录所有状态都可回溯。我用的是事件溯源模式不直接修改状态而是记录状态变更事件状态由事件重放得到。比如会话状态我不存current_state字段而是存一个事件列表session_created、message_received、tool_called、tool_returned、message_sent。当前状态通过重放这些事件计算得到。这样任何时刻的状态都可以通过重放事件复现出问题时可以精确定位到哪个事件导致了异常。class SessionState: def __init__(self): self.events [] self.messages [] self.tool_calls [] def apply(self, event): self.events.append(event) if event[type] message_received: self.messages.append({role: user, content: event[content]}) elif event[type] message_sent: self.messages.append({role: assistant, content: event[content]}) elif event[type] tool_called: self.tool_calls.append({name: event[name], args: event[args]}) classmethod def from_events(cls, events): state cls() for event in events: state.apply(event) return state事件溯源的代价是存储量增大查询时需要重放。优化方案是定期做快照快照增量事件重放。我设置的是每100个事件做一次快照查询时先加载最近快照再重放之后的事件。5.3 失败可恢复的设计重试、降级与熔断失败可恢复是Agent系统稳定性的关键。我的设计有三层重试、降级、熔断。重试是最基本的。工具调用失败时先重试2次间隔1秒和3秒。重试时检查失败原因如果是网络超时则重试如果是参数错误则不重试直接返回。降级是重试失败后的备选方案。比如存储后端写入失败降级到内存缓存同时记录待同步队列等存储恢复后补写。MCP Server连接失败降级到本地工具实现功能可能受限但不至于完全不可用。熔断是防止雪崩。如果某个工具连续失败超过阈值比如5次熔断器打开后续调用直接返回失败不再尝试。等一段时间后比如30秒进入半开状态允许一次试探调用成功则关闭熔断器失败则继续打开。class CircuitBreaker: def __init__(self, threshold5, timeout30): self.threshold threshold self.timeout timeout self.failures 0 self.last_failure_time 0 self.state closed # closed, open, half-open def call(self, func, *args, **kwargs): if self.state open: if time.time() - self.last_failure_time self.timeout: self.state half-open else: raise Exception(Circuit breaker is open) try: result func(*args, **kwargs) if self.state half-open: self.state closed self.failures 0 return result except Exception as e: self.failures 1 self.last_failure_time time.time() if self.failures self.threshold: self.state open raise e这套机制组合起来系统在部分组件故障时仍能提供服务用户体验不会断崖式下降。5.4 常见问题速查表与排查思路我把实际运维中遇到的问题整理成速查表方便快速定位。问题现象可能原因排查方法解决方案Agent回复重复内容存储后端重复写入检查消息表是否有重复记录加唯一索引写入前去重工具调用超时沙盒执行时间过长查看沙盒日志确认执行时间调整timeout参数优化代码MCP连接失败协议版本不匹配检查initialize握手日志升级Client或Server版本内存持续增长会话未清理检查活跃会话数量和内存占用加会话过期清理定期重启沙盒逃逸资源限制未生效检查preexec_fn是否执行确认平台支持换用容器方案状态不一致并发写入冲突检查是否有并发写同一会话加锁或改用乐观锁日志混入协议数据print输出到stdout检查stdout内容重定向print到stderr排查思路我总结为从外到内、从简到繁。先检查最外层的网络和进程状态再检查中间层的协议和存储最后检查最内层的代码逻辑。大部分问题出在中间层比如存储写入失败、协议解析错误、资源限制未生效。提示我习惯在每个关键路径加日志但日志级别要控制好。DEBUG级别用于开发调试INFO级别用于关键操作WARN级别用于异常但可恢复的情况ERROR级别用于需要人工介入的故障。生产环境默认INFO级别出问题时临时调到DEBUG。5.5 性能调优的实战经验性能调优没有银弹只能根据瓶颈逐个优化。我按优先级排列存储IO 沙盒启动 协议通信 代码执行。存储IO通常是最大瓶颈。优化手段包括批量写入代替逐条写入用连接池代替每次新建连接用缓存减少重复查询。我实测批量写入比逐条写入快10倍以上。沙盒启动开销也不小。每次执行代码都fork子进程、设置资源限制、创建临时目录开销在几十毫秒。优化方案是沙盒池化预先启动一批沙盒进程执行时从池里取执行完归还。这样启动开销分摊到多次执行单次延迟降到几毫秒。协议通信的优化主要是减少往返次数。MCP的list_tools结果可以缓存不需要每次调用都重新发现。工具调用的参数和结果尽量精简避免传输大对象。代码执行的优化是最后考虑的。大部分Agent生成的代码本身不复杂执行时间在毫秒级。如果确实有计算密集型任务考虑用更高效的算法或换语言实现。我在实际项目中的体会是先测量再优化。不要凭感觉猜瓶颈用profiler工具定位真正的热点。我试过花一天优化代码执行结果发现瓶颈在存储IO白忙一场。后来养成习惯先用cProfile和py-spy定位再针对性优化效率高很多。最后分享一个小技巧给每个请求打上trace_id从Agent接收请求到返回结果所有日志都带上这个ID。出问题时用trace_id串起所有相关日志排查效率提升数倍。这个习惯我从微服务架构带过来在Agent系统里同样适用。
返回列表