尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

Agent-Reach:给大模型装上可控、可审计的“手”

Agent-Reach:给大模型装上可控、可审计的“手” 我的AI助手帮我写了半年代码却连一个文件都没帮我部署过。它能在我提问时滔滔不绝地解释Redis Cluster的故障转移原理但当我问“帮我把这条告警规则写到配置里”时它只会回复我一段配置文件内容然后让我自己复制粘贴。这种“能说不能做”的状态在我开发内部工具链的第三个月彻底让我崩溃了。于是我动手写了Agent-Reach一个专注于解决“智能体触达真实系统”问题的个人项目。Agent-Reach这个名字是agent智能体和reach触达的组合。它想解决的核心问题非常简单如何让大语言模型从“生成文本”跨越到“执行动作”而且每一次执行都可控、可审计、可回滚。如果你也在搭建个人AI代理、内部自动化工具或者想让大模型帮你真正操作点什么——而不只是输出Markdown表格——这篇文章应该能给你不少参考。1. 为什么不给大模型一双“手”Agent-Reach 的起点和设计目标1.1 从“聊天AI”到“行动AI”的那道坎很多人以为构建一个能行动的AI代理只要调用大模型的Function Calling就够了。我一开始也是这么干的但很快发现事情没那么简单。Function Calling确实能让模型输出结构化的函数调用参数但它只解决了“模型愿意调用工具”这个问题。真正麻烦的是后面那串连锁反应模型调用了工具之后返回结果怎么处理这个工具的权限边界在哪它能不能访问生产环境如果模型连续调用三个工具中间哪个环节出了问题如何定位和回滚我试过给一个模型直接挂上数据库查询函数结果它在一次会话里连跑了八条查询其中有两条是因为第一条结果不够“完美”它才重新查的——浪费了时间还差点把线上数据库的慢查询日志刷屏。也就是说让模型“能伸手”很容易让这只手“不乱摸、摸错了能缩回来”才是真正的难点。Agent-Reach的设计初衷就是给模型的一双手装上关节、肌肉和神经——让每一个动作都有明确的路径、边界和反馈。1.2 “触达”的定义与三层能力模型要设计一个行动系统第一步是把“触达”这个概念拆清楚。我把Reach能力分为三层可读Read查询、检索、观察。这一层不改变任何外部系统状态比如跑一条SELECT语句、读取一份日志文件、调用一个只读API。可写Write修改配置、创建资源、发送请求。这一层会真实改变外部系统的状态比如更新数据库记录、写入配置文件、触发一个部署任务。可控Control权限、审计、回滚。这一层决定了上面两类动作在什么条件下被允许执行以及执行后留下什么痕迹。Agent-Reach的核心设计目标不是让模型变得“更聪明”而是让这三层能力有清晰的边界每一层都能独立配置、独立审计。简单说你可以让模型读一百遍数据库但写一次数据库必须经过确认。1.3 与Function Call、MCP 相比Agent-Reach 的差异化定位在做技术选型时我认真对比了几种主流方案方案核心优势我遇到的痛点原生Function Calling模型原生支持接入快权限判断散落在业务代码里每个工具都要自己写安全逻辑MCPModel Context Protocol工具描述标准化生态活跃工具定义和权限策略整体暴露给模型侧服务端审计不占主导自建Action协议Agent-Reach权限、路由、审计全部收归服务端需要自己定义协议前期工作量更大这个对比不是说MCP不好而是我个人的项目诉求不一样。Agent-Reach更像一个“行动网关”模型只负责生成遵守协议的调用请求真正能不能执行、怎么执行、要不要通知用户全部由服务端的路由与审计层决定。这让我在接入任意模型时都更安心。2. Reach 协议让自然语言落到真实动作上的行动描述规范2.1 协议长什么样一个工具调用的完整JSON结构Reach协议是整个系统的核心契约。我把它设计成一个严格的JSON动作描述每个动作都必须包含足够的信息让服务端不需要任何额外猜疑就能决定是否放行。下面是一个真实的动作示例{ protocol_version: 1.0, request_id: req_9f2c1a7e3b, trace_id: trace_8d0f6a2c91, timestamp: 1735789200, tool: database.query, action: execute_select, parameters: { statement: SELECT count(*) FROM orders WHERE created_at now() - interval 1 day, database: orders_prod_readonly, row_limit: 100 }, expected_return: integer:订单数量, timeout_ms: 5000, danger_level: 0, confirmation_required: false }每个动作从生成到执行完整经过“解析 → 校验 → 路由 → 执行 → 审计”五步。后面每一步都会用到这个结构里的字段所以设计时必须一次想清楚。2.2 为什么每个字段都必须显式存在我最初设计协议时字段比这个少得多后来经过实际使用逐步补齐每一个字段都有它存在的理由request_id和trace_id这两个ID贯穿整个执行链路是审计与排查问题的关键索引。出了问题直接按ID搜索日志即可。danger_level这是我最强调的字段。它标识了这个动作的危险等级并且放在动作本身而不是工具上。为什么因为同一个工具在不同场景下危险程度完全不同——查一条订单记录是等级0批量删除订单就是等级3。expected_return让模型提前知道调用会得到什么可以有效减少幻觉。模型看到“integer:订单数量”就知道返回值是数字不会把一段SQL查询结果幻想成自然语言。confirmation_required这个字段并非由模型自行决定而是服务端根据danger_level自动生成的。但让它显式出现在协议里可以让模型在生成时就意识到这是一个“需要确认”的动作。2.3 服务端如何解析和校验这条协议协议到达服务端后第一件事是校验不是执行。我使用pydantic定义了一个字段校验模型from pydantic import BaseModel, Field, ValidationError from typing import Any, Optional class ReachAction(BaseModel): protocol_version: str 1.0 request_id: str trace_id: str timestamp: int tool: str action: str parameters: dict[str, Any] expected_return: str timeout_ms: int Field(ge100, le30000) danger_level: int Field(ge0, le3) confirmation_required: bool False校验的规则不只是字段类型还包括语义检查这个tool是否存在于服务端的工具注册表中danger_level是否与该工具允许的上限冲突参数是否符合该工具声明的JSON Schematimeout_ms是否在允许范围内任何一个校验不通过请求直接终止不会进入路由层。这个设计让我放心把Reach协议暴露给任意模型因为即使模型胡言乱语服务端也不会真的执行什么。3. 四层架构暴露层、路由层、执行层与审计层各自要解决什么3.1 暴露层决定模型“能看见什么工具”暴露层Exposure是模型与工具之间的窗口。它不负责执行任何动作只负责回答一个问题模型在这一轮对话中能看见哪些工具这个看似基础的层其实隐藏了很多细节。我一开始把全部工具描述都塞给模型结果它反而在两个高度相似的函数之间反复犹豫。后来我改成按需暴露——根据当前任务上下文只把一个相关的子集给模型。比如用户问“订单错误率怎么样”暴露层只开放数据库只读查询和日志检索工具而不是把部署、配置、通知这些工具全部摆在模型面前。这很像给一个实习生分配任务时没必要让他看到保险柜的钥匙串。每类工具在暴露层有一个manifest描述类似这样{ tool: log.retrieve, description: 按时间范围和关键词检索应用日志, parameters_schema: { service: {type: string, required: true}, start_time: {type: string, required: true}, end_time: {type: string, required: true}, keyword: {type: string, required: false} }, danger_level_max: 0, timeout_limit_ms: 15000 }注意danger_level_max这个字段它决定了这个工具在暴露层就被限制了最高危险等级。即使模型对这个工具生成了危险等级为2的动作服务端校验时也会拒绝。3.2 路由层从意图到真实命令的翻译路由层Routing是Reach协议的翻译官它负责把模型输出的抽象动作转换成可以真实执行的系统命令或API调用。这里有一个重要设计原则模型永远不直接接触底层命令。比如模型输出的是{tool: database.query, action: execute_select}路由层会把动作解析成真实的SQL执行器调用并且在此之前会做一次“存在性检查”。我在实际开发中遇到过一次模型幻觉调用了一个不存在的工具名就是靠这一层校验拦下来的。路由层还负责把动作分发到正确的执行器。针对不同的工具类型我实现了不同的Router数据库路由器检查SQL语句类型SELECT走只读执行器UPDATE/DELETE走写执行器。脚本路由器将动作映射到sandbox目录下的具体脚本不允许路径穿越。通知路由器调用内部消息服务发送通知校验收件人和内容长度。每个路由器都只是一个干活的桥梁不包含任何权限判定逻辑。权限判定被集中放在一个独立模块里——这样才符合“单一职责”的原则也让权限策略的修改不需要动路由代码。3.3 执行层权限判定、超时控制与进程隔离执行层Execution是真正“动手”的地方。它接收路由层已经翻译好的命令做最后一次权限判定然后在一个受控环境里运行。我使用Python的subprocess模块执行所有外部命令而不是直接在主进程中调用。这样做的原因是隔离性即使脚本崩溃或者无限循环也不会拖垮整个Agent-Reach服务。一个典型的执行器长这样import subprocess import os import tempfile def run_isolated_command(cmd, env_override, timeout_ms): with tempfile.TemporaryDirectory() as sandbox: env os.environ.copy() env.update(env_override) env[SANDBOX_DIR] sandbox result subprocess.run( cmd, shellFalse, capture_outputTrue, textTrue, timeouttimeout_ms / 1000, envenv, cwdsandbox, ) return { stdout: result.stdout, stderr: result.stderr, returncode: result.returncode, }这个执行器有几个细节值得注意每个命令都在一个全新的临时目录中运行避免上一次运行残留文件影响下一次。环境变量通过env_override注入真实的数据库密码、API密钥永远存放在服务端的密钥管理里不会出现在模型可读的任何上下文里。cwd被强制设为sandbox目录防止脚本意外读取宿主机的任意路径。超时控制也是执行层的必修课。我遇到过模型调用一个耗时超过5分钟的日志分析命令导致整个Agent阻塞。现在每个执行器都有严格超时超时后直接返回错误信息给模型。3.4 审计层每一次触达都留下证据审计层Audit是整个系统里最枯燥但也最重要的一部分。它记录每一次从模型发出的Reach动作、执行结果、耗时、由哪个会话发起、最终是否被执行。我采用的存储格式是JSONL每行一个JSON对象方便按时间顺序追加写入也方便后续用命令行工具快速查询。一条典型的审计记录长这样{ timestamp: 2025-01-02T10:32:18Z, request_id: req_9f2c1a7e3b, trace_id: trace_8d0f6a2c91, session_id: sess_alpha_01, tool: database.query, action: execute_select, danger_level: 0, status: completed, duration_ms: 182, result_summary: count1284, user_acked: false }这个审计日志是整个系统的“黑匣子”。有一次我误删了一个配置项就是靠审计日志定位到是哪次会话、在什么时间、由哪个用户确认执行的。没有这层记录Agent系统的信任根本无从谈起。当某个动作因为危险等级过高被拒绝时审计层同样会记录一条status为denied_by_policy的记录。这些拒绝记录非常宝贵它们能告诉我模型在哪些边界试探频繁帮助我优化后续的暴露策略和提示词。4. 权限与边界什么叫触达什么叫越权4.1 危险等级把操作分为四档我定义了四级危险等级对应从“完全无害的读操作”到“影响生产环境的变更操作”等级名称典型操作执行策略0只读SELECT查询、日志检索、获取状态自动执行不通知用户1本地变更生成文件、本地构建、草稿写入自动执行但记录审计2敏感操作写入配置、修改数据库记录、发送外部通知用户确认后方可执行3生产变更部署、回滚、批量数据修改双重确认用户在终端管理接口同时批准这个分级表不是我凭空拍脑袋定的。我们内部工具链经历过一次“模型自动发通知”的事故模型把一个内部提示通知发给了外部协作群幸好很快就撤回了但这件事让我意识到发通知这种看似无害的操作一旦跨出边界比数据库写操作更难收拾。所以“敏感操作”不只看它修改了什么更要看它影响到了谁。4.2 服务端强制校验模型再聪明也不能绕过权限判断绝不能只靠模型自律。我把权限校验做成了一个独立的中间件任何Reach动作在进入路由层之前都必须经过它。def enforce_policy(action: ReachAction, session_context: SessionContext) - PermissionDecision: if action.danger_level session_context.max_allowed_danger_level: return PermissionDecision.deny(danger_level exceeds session max) if action.tool not in tool_registry: return PermissionDecision.deny(tool not registered) tool_policy tool_registry[action.tool] if action.danger_level tool_policy.danger_level_max: return PermissionDecision.deny(tool danger_level exceeded) if action.danger_level 2 and not action.confirmation_required: return PermissionDecision.deny(confirmation missing for dangerous action) return PermissionDecision.allow()这段代码的核心逻辑是拥有管理员权限的会话虽然可以在创建时设定更高的max_allowed_danger_level但单个工具的上限danger_level_max依然由工具定义锁死。就算当前会话拥有最高权限模型想对一个只读API执行写操作照样被拒绝。4.3 二次确认与沙箱我给Agent装的两道保险危险等级为2或3的动作会进入“待确认”状态。这个状态有一个独立的确认接口支持三种确认方式终端交互确认Agent在命令行中输出黄色的待确认提示用户输入y确认或n拒绝。HTTP回调确认通过一个带签名token的URL进行确认适合远程操作场景。消息推送确认当Agent-Reach部署在手机可触达的环境中推送一条格式化的确认请求。确认信息必须包含动作摘要、影响范围和一条“不可撤销提示”。我把确认文案设计得非常明确避免用户无意识地点了“允许”比如“确认执行将订单表orders中过期时间在2025-01-01之前的记录标记为已归档。预计影响1200行数据。此操作可通过审计记录追踪但不可自动撤销。”第二道保险是沙箱。所有危险等级为1以上的动作在进入执行层时都会被包裹到一个受限环境中。这个环境默认无外部网络访问、文件系统只读、无真实凭据注入。对于需要用真实凭据的动作比如连接生产数据库我会在沙箱内使用一个短暂有效的凭据且该凭据只能访问白名单内的资源。这两道保险的组合效果是即使模型的提示词注入攻击成功了攻击者能做的也只是触发一条待确认请求或者在一个空壳沙箱里打转。5. 一个完整的落地案例让Agent-Reach去查昨晚的错误率5.1 任务拆解系统内部发生了什么理论讲再多不如跑一个实际任务。我在公司内部测试环境跑了一个真实场景用户输入是“帮我看看昨天晚上10点到12点订单服务的错误率把趋势整理成表格。如果超过5%替我起草一条告警规则但先别启用。”这条指令看似一句话但系统内部要拆成五个子步骤意图解析识别出这是一个多动作复合任务。只读查询获取订单服务在指定时间段的日志和错误计数。数据分析计算错误率、按小时分组。生成报告把结果整理成Markdown表格。起草告警规则生成一条待确认的告警配置。这个拆解过程在Agent-Reach中是通过“任务规划器”完成的。它不是一个独立的大模型而是基于Reach协议的回流机制首轮模型输出一个查询动作拿到结果后再决定下一步。5.2 只读阶段查询与分析第一轮实际执行的动作是{ request_id: req_log_20250102_001, tool: log.retrieve, action: execute_search, parameters: { service: order-service, start_time: 2025-01-01 22:00:00, end_time: 2025-01-01 23:59:59, keyword: error }, danger_level: 0, confirmation_required: false }这个动作危险等级为0直接执行。日志检索器在沙箱内读取了预下载的日志分片我们不允许Agent直接实时读生产日志全文而是提供一个只读快照接口返回了分时错误计数。随后模型基于结果计算每小时错误率生成表格。这一步没有产生任何新的Reach动作——表格生成是在Agent-Reach内部的文本处理模块中完成的不调用外部工具。5.3 写操作阶段等待用户确认报告生成后模型根据预设逻辑发现错误率超过5%准备起草告警规则。这一步涉及写入配置危险等级为2系统自动将动作状态置为pending_confirmation。审计日志此时记录了一条confirmation_requested事件同时在终端打印了以下信息[Agent-Reach] 请求确认 动作: 写入告警规则至 config/alerts.yaml 影响: 新增一条基于错误率的告警频率5分钟通知渠道: [内部工单] 风险等级: 敏感等级2 输入 y 确认执行n 拒绝_我在终端输入了n拒绝执行。原因很简单我只是让模型“起草”规则并不想让它直接写入配置文件。这个选择完全符合预期——系统的价值就在于它给了我在最后一刻说“不”的机会。5.4 执行结果与审计留痕整个过程结束后我查看审计日志发现系统如实记录了每一步req_log_20250102_001只读日志检索执行成功耗时340ms。req_report_20250102_002本地生成Markdown表格等级1执行成功。req_alert_20250102_003起草告警规则等级2被用户拒绝未执行。四条记录之间通过trace_id串联从开始到结束没有丢失任何环节。我直接在终端用一条grep命令就把这次会话的完整执行链捞了出来——这种透明感是让我放心把Agent接入真实系统的唯一前提。6. 开发与运营中踩过的四个坑6.1 坑一把全部工具文档塞进Prompt模型反而“迷路”我第一次构建Agent-Reach的暴露层时犯了一个新手错误为了让模型“更聪明”我把所有工具的定义、参数、示例、限制条件全部放进了system prompt结果整个上下文直接飙到32K token以上。模型的反应出乎意料地糟糕它对两个功能类似的工具database.query和database.execute反复犹豫一会儿调用这个一会儿又换成那个输出格式也经常不稳定——有时候把参数对象当成字符串传进去有时候连tool字段都填错。解决办法是对暴露层做了彻底的“按需注入”改造。具体做法是先通过一个快速意图分类器判断用户请求属于哪个任务域再有针对性地加载工具描述子集。比如用户问“错误率”只加载日志检索和数据库只读查询的描述用户提到“部署”才加载发布相关的工具描述。改造后实测的调用格式稳定率从71%提升到了94%上下文体积也大幅缩小。这件事让我学到一个道理给模型的信息不是越多越好而是越精准越好——就像给一个新人派活你只需要告诉他今天要做的三件事而不是把整个部门的KPI都砸给他。6.2 坑二模型幻觉调用了一个不存在的工具有一次模型调用了一个叫elasticsearch_health_check的工具但我的工具注册表里对应的名字是es_health_status。如果路由层直接把这个动作映射到一个不存在的执行器系统就会抛出一个无意义的KeyError异常。更麻烦的是模型在后续对话里会反复尝试这个不存在的工具因为它认为自己已经在上一次调用中得到过结果——但实际上那次调用失败了。这会形成一个幻觉循环浪费大量解析时间和token。修复方式是在路由层增加一道“工具存在性检查”if action.tool not in tool_registry: return RouteResult.error( error_codetool_not_found, messagef工具 {action.tool} 不存在。可用工具列表{list(tool_registry.keys())[:20]} )我没让路由层直接报错而是把现有工具列表返回给模型让模型在下一次调用时参考。这个简单的反馈机制让模型的幻觉调用问题在几轮对话内自动纠正而不是卡死。另外我还给工具名内置了别名解析机制。elasticsearch_health_check可以被配置为es_health_status的alias这样即使模型记错了名字系统也能把它映射到正确的工具。但别名只针对那些确实容易混淆的工具不能滥用否则会重新引入“模型分不清两个工具”的问题。6.3 坑三并发请求把会话状态搅成一锅粥Agent-Reach最初设计时没有充分考虑并发场景。我以为它只会在一个终端会话里运行结果有一次我从命令行和HTTP接口同时发起了两个请求两个请求都指向同一批Redis键。第二请求的执行器读到了第一请求写入的中间状态直接把结果算错了。排查这个问题的过程相当费劲审计日志显示每个动作都成功了但结果却是错的——因为日志只记录了动作本身没有记录并发时的状态交错。后来我仔细查看了执行器的上下文才发现问题出在共享的环境变量和临时目录上。修复方案是给每个会话分配独立的执行上下文session_context { session_id: session_id, sandbox_dir: f/tmp/agent_reach/sandbox_{session_id}, env: { AGENT_REACH_SESSION_ID: session_id, DATABASE_READONLY_URL: get_session_scoped_credential(session_id), } }关键点是每个会话都有自己的临时目录、自己的只读凭据、自己的环境变量副本。一个会话的失败和残留不会影响另一个会话。这相当于给每个“数字实习生”都配了一个独立的工位和独立的电脑而不是所有人抢一台公共机器。并发安全是一项能力而不是一个功能它不会在你第一次跑通时显现价值但会在某天突然拯救你一次。6.4 坑四误操作之后没有后悔药有一次我给测试环境写了一个清理脚本本来只应该删除一批过期缓存键结果因为参数写错了把--older-than 30d写成了--older-than 3d删掉了一批还在正常服务的缓存数据。虽然测试环境最终恢复了但那次经历让我意识到执行层必须有能力“撤销”。Agent-Reach目前的解决方案分三层对于支持事务的工具比如数据库写操作在执行前开启事务执行后根据结果提交或回滚。对于不支持事务的工具比如写配置文件在执行前自动备份原始文件到一个带时间戳的备份目录并生成一条undo_script关联记录。对于无法自动回滚的物理操作比如删除一个云资源实例只允许危险等级为3且双重确认后执行并必须在审计日志里记录发起人和审批人。给执行层加了备份和undo机制之后我整个人的操作心态都变了——以前是一步一回头生怕哪条命令干了不该干的事现在即使真的搞砸了至少能用五分钟回到起点。7. 下一步的方向我正在试错的三个扩展Agent-Reach的核心已经跑通我也在实际项目中用了一段时间。现在我在三个方向上继续做扩展其中有一些已经能用有一些还在试错阶段。7.1 Reach缓存层让重复调用更便宜我在使用中发现一个现象在一个长会话里模型经常重复查询同一个时间段、同一个服务、同一个关键词的日志。每次查询都重新扫描日志快照既慢又浪费。我正在给只读动作加一个缓存层对完全相同的工具参数组合在10分钟窗口内直接返回缓存结果并打上cached: true标签。这样模型第二次查询时能更快拿到结果日志系统也不会被重复扫描打爆。缓存层的设计难点是可失效性如果数据变更了必须有机制清掉相关缓存。我目前的方案是对日志快照类数据按索引版本管理一旦有新的快照写入自动失效旧的缓存。7.2 回放评测集用真实历史任务评估准确率这是我下一个重点方向。现在有一批真实的会话日志包含用户的原始请求、模型的动作序列、以及最终执行结果。我准备用这批日志做一个“回放评测集”每次改动Agent-Reach的路由策略或暴露层逻辑时把历史请求重新跑一遍看模型的动作序列和最终结果是否与日志中实际记录的一致。这个评测集的意义在于它可以避免“改一处逻辑、坏一堆行为”的回归问题。比如我调整了暴露层的工具子集影响面到底有多大通过回放评测集我能看到有多少会话因为工具不匹配而走到了完全不同的动作路径。7.3 走出终端把Action能力封装成对外API目前Agent-Reach只运行在我的本地终端和内部服务器上。但“给AI装上手”这件事的价值不应该只局限在终端。我计划把Reach协议封装成一个面向其他应用的HTTP API让手机上的快捷指令、其他内部工具、甚至是另一个AI代理都能通过这个API请求Agent-Reach执行动作。这意味着API层必须实现更强的身份认证和速率限制。不能任何调用方都能发起一条danger_level3的请求。我的初步设计是每个外部调用方有自己的API Key对应一个固定的max_allowed_danger_level且所有的外部调用都默认强制开启确认回调除非调用方在配置中明确关闭。这个扩展一旦完成Agent-Reach就从“我的个人玩具”变成了“团队可以共用的一套行动基础设施”。在我自己把Agent-Reach接到本地数据库和运维工具之后最大的感受是一个原本只会“说”的系统终于开始“做”事了。但“做”事比“说”事难得多——你要管的不是模型的天才想法而是每次行动踩到真实世界边界的分寸感。我的建议是别一上来就给它生产环境的控制权先让它从最安全的事情做起只读查询、日志检索、生成脚本草稿跑通之后再逐步开放写权限。先让它打扫房间而不是直接接管厨房的燃气灶。等你看到它把一个复杂的查询任务拆成几步、每一步都留下清晰的审计轨迹时你就能理解让AI“触达世界”这件事本身就是在给世界安装一套安全阀。
返回列表