拓冰建站拓冰建站
首页 / 资讯中心 / 正文

内网服务部署与Agent权限隔离实战:从Nginx到Hermes

实习第五周任务量突然就上来了。前四周我一直在熟悉代码库、改小功能、补测试这周直接给我安排了三件事把一个内部工具站部署到测试服务器、在公司内网环境里配置一套本地Agent、再基于这套东西做一个权限隔离的Demo。从结果看这三件事其实是同一条链路——网站负责入口Agent负责能力权限隔离负责边界。这篇就当周记复盘把部署流程、选型理由、踩坑过程都写清楚给后面接这个活的同学留个参考。先说结论整个过程花了三个整天加两个晚上网站部署和Agent配置本身不算难真正磨人的是权限隔离那个Demo。因为Agent一旦能调用工具它的权限边界就不只是“谁能访问这个页面”而是“这个请求能替用户执行到什么程度”。这个抽象问题如果不落地成具体的校验链路Demo做出来也是花架子。1. 网站部署为什么我没用Docker而是选择systemd加Nginx我接手的是一个内部小工具站后端是Python写的提供一堆查询接口前端就几个页面。按我平时的习惯这种服务直接Docker Compose一套带走最省事。但看了一眼测试服务器的环境我放弃了。服务器是台老机器内核版本偏低Docker装倒是能装但跑起来性能损失明显。更关键的是公司内部有统一的部署规范测试环境要求服务直接挂在systemd下由运维的监控脚本统一拉取状态。这个背景下我与其再套一层容器不如直接按规范来。1.1 部署方案选型时我在想什么当时摆在面前的有两套方案方案优点缺点Docker Compose环境隔离好复现容易内网环境镜像拉取受限老内核有兼容性隐患不符合组内规范systemd Gunicorn Nginx符合公司规范运维监控直接可用故障排查链路短依赖管理要自己来升级要手动处理我选了后者。说实话如果你在个人服务器上部署那Docker依然是首选但如果你也是在公司内网、老服务器、有统一运维规范的环境下做事跟着规范走永远比秀技术重要。这个选择没有对错之分只有环境适配的问题。依赖管理我用的是uvPython 3.11的虚拟环境。用uv而不是pip直接装是因为它在依赖解析和安装速度上明显更快而且会把依赖版本锁死在一个uv.lock里后面服务重新部署、依赖回滚都很方便。我甚至在这个环节踩过一个坑——最初直接用pip install -r requirements.txt装结果Passion装了个较高的版本接口返回的数据结构变了前端直接白屏。后来改成uv加锁文件才把版本一致性保住。1.2 systemd服务单元文件怎么写才不容易出问题Gunicorn作为WSGI服务器来启动Python应用。这里我建议不要用Flask自带的开发服务器那个单进程、无并发保护线上环境撑不住。下面是我最终用的service文件关键参数都加了注释[Unit] DescriptionInternal Tool Web Service Afternetwork.target [Service] Userwww-data Groupwww-data WorkingDirectory/srv/internal-tool EnvironmentFile/srv/internal-tool/.env ExecStart/srv/internal-tool/.venv/bin/gunicorn \ --workers 3 \ --threads 4 \ --worker-class gthread \ --timeout 60 \ --graceful-timeout 30 \ --bind 127.0.0.1:8000 \ run:app Restartalways RestartSec3 PrivateTmpfalse [Install] WantedBymulti-user.target几个容易踩的细节EnvironmentFile指向.env文件数据库连接串、密钥、外部API地址都放这里而不是直接写死在代码里。这样环境切换时只需要换文件不需要改代码。User我特意用了www-data而不是root避免服务进程权限过大。PrivateTmpfalse这个字段后面在Agent配置那节我会重点讲这周我在这里面亏了一个多小时。--worker-class gthread加上--threads 4适合IO密集型的查询服务。如果换成纯计算型任务用gevent或uvicorn的httptools更合适。写完unit文件后执行sudo systemctl daemon-reload sudo systemctl enable internal-tool sudo systemctl start internal-tool启动之后立刻看状态和日志systemctl status internal-tool journalctl -u internal-tool -f1.3 Nginx反向代理的细节斜杠和缓存Nginx那层核心是反向代理和静态文件处理。下面是我用的配置server { listen 80; server_name tool.internal.example.com; return 301 https://$host$request_uri; } server { listen 443 ssl http2; server_name tool.internal.example.com; # ssl_certificate 和 ssl_certificate_key 由内部CA签发 location /static/ { alias /srv/internal-tool/static/; expires 7d; access_log off; } location /api/ { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } location / { proxy_pass http://127.0.0.1:8000/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }这里有两个细节必须单独拎出来说。第一proxy_pass末尾的斜杠问题。location /api/配proxy_pass http://127.0.0.1:8000和配http://127.0.0.1:8000/转发后后端收到的路径是不一样的。前者会保留/api/前缀后者会去掉。这个我没少在这上面吃过亏后来总结出一个规则如果后端路由本身不带/api前缀那proxy_pass末尾就加斜杠如果后端路由带了就不加。第二静态文件用alias而不是root。具体区别不展开说了你只需要记得alias是把URL路径映射到服务器上的指定目录root会把完整URL路径拼在root目录后面用错了会导致CSS、JS全部404。HTTPS证书走的是内部CA签发不需要花钱。如果你在公网环境可以用acme.sh配合DNS API自动续期效果一样。到这里网站已经能在浏览器里正常访问了。但这只是第一件事后面等着我的Agent配置才叫真的折腾。2. 本地Agent配置从Hermes Agent选型到真正跑通的完整链路工具站部署好之后mentor扔给我第二个任务公司内部要做一个基于大模型的信息助手但数据不出内网所以必须在本地部署一个Agent服务。当时组里已经有人在调研Hermes Agent说是支持工具调用、支持本地模型、也支持MCP服务。我接到的任务是把它配起来先跑通一个能回答内网文档问题的版本。2.1 为什么选Hermes Agent做本地部署选择Hermes Agent不是因为它在开源社区最流行而是它的定位恰好卡在这个需求点上。它支持多种模型后端包括Ollama、vLLM、以及OpenAI格式的兼容接口。在内网环境这意味着就算没有GPU也可以用API方式先跑通流程。它原生支持MCP服务注册。MCPModel Context Protocol说白了就是给Agent装工具的标准接口协议有了它Agent可以调用外部工具比如查数据库、读文档、发HTTP请求。它的工具调用function calling链路是完整的。模型决定要调什么工具Agent解析这个决定实际去执行再把结果返回给模型生成最终回答。这是做Agent的关键比那些只能简单聊天的封装高级很多。安装过程比较直接git clone https://github.com/hermes-agent/hermes-agent.git cd hermes-agent conda create -n hermes python3.11 -y conda activate hermes pip install -e .模型这块开发环境没有单独的GPU我用Ollama先跑一个量化版的小参数模型模型名字是qwen2.5:7b。虽然7B的推理质量不算惊艳但验证链路足够。2.2 配置文件最容易挖坑的地方模型参数和工具权限Hermes Agent的配置文件在~/.hermes/config.yaml核心配置大概长这样model: provider: ollama model_name: qwen2.5:7b temperature: 0.3 max_tokens: 2048 context_window: 8192 server: host: 127.0.0.1 port: 8080 mcp: servers: - name: internal-docs command: python args: [/srv/mcp-servers/docs_server.py] env: DOCS_ROOT: /srv/internal-docs tools: enabled: - web_search - read_document - execute_sql disabled: - shell_exec这里有几个从实操中得到的经验我一个个说。temperature设的是0.3不是默认的0.7。因为这个应用要回答内部文档问题要求答案稳定、忠实于文档内容而不是天马行空。如果做创意生成类的任务temperature才需要调高。max_tokens和context_window要匹配模型的实际能力。很多人把context_window设成模型支持的最大值比如128K但本地推理时context越长显存占用越大生成速度越慢还容易OOM。先设8K跑通流程后面有需要再加。tools.enabled和tools.disabled是权限控制的第一道闸门。shell_exec这种工具在Demo阶段我直接禁用了理由很简单一旦Agent可以被任意用户通过API触发一个不走脑子的prompt注入就可能让Agent执行危险命令。工具能力越大责任越大。配置好了之后启动hermes serve --config ~/.hermes/config.yaml这时Agent会监听在127.0.0.1:8080提供一个符合OpenAI格式的/v1/chat/completions接口。这里有一个重要设计即使Agent和Web服务在同一台机器也建议用127.0.0.1而不是0.0.0.0。因为Agent服务不应该直接对局域网暴露它只能被Web后端调用暴露面越小越安全。2.3 把Agent嵌入Web后端API调用和MCP工具注册的配合Agent服务跑起来了接下来就是把它接到网站后端。我采用的是最简单的HTTP调用方式在FastAPI的某个接口里同步去请求Hermes的/v1/chat/completions接口拿到模型回复后再返回给前端。大致逻辑from fastapi import APIRouter, Request import httpx router APIRouter(prefix/api/agent) router.post(/chat) async def chat_with_agent(request: Request): body await request.json() user_message body[message] user_id request.state.user_id system_prompt ( f你是公司内部信息助手。当前用户ID是{user_id}。 你只能回答基于内部文档的问题拒绝一切与工作无关的请求。 ) async with httpx.AsyncClient(timeout120) as client: resp await client.post( http://127.0.0.1:8080/v1/chat/completions, json{ model: qwen2.5:7b, messages: [ {role: system, content: system_prompt}, {role: user, content: user_message} ], tools: [read_document, execute_sql], user_id: user_id } ) return resp.json()注意我把user_id放到了system prompt里同时也作为独立字段传给了Agent。这个看似简单的设计实际上是为后面权限隔离Demo铺路。到了这一步我手里已经有了一条完整的调用链浏览器 → Nginx → Web后端 → Hermes Agent → 本地模型/工具。接下来要做的就是权限隔离Demo让不同用户只能看到自己该看的东西。3. 权限隔离DemoRBAC模型与Agent调用链的边界设计权限隔离Demo是这周最耗脑子的任务核心需求一句话平台上有多个部门的数据用户A登录后只能查询自己部门的数据即使用户A通过Agent提问“把B部门的数据给我导出来”系统也要拦得住。3.1 Demo要证明什么不证明什么先理清需求边界很多权限隔离Demo做砸了是因为没想清楚要证明什么。我的理解是这个Demo要证明两件事不同用户访问Web API时后端能根据其身份返回不同的数据视图。不同用户向Agent提问时Agent的工具调用链路能感知到用户身份并在读取数据时做行级过滤。不需要证明的Agent模型本身有多聪明、回答有多自然。这些和权限隔离无关。基于这个理解我设计了两层校验第一层在API网关负责做身份认证和粗粒度权限判断第二层在Agent工具内部负责做数据行级过滤。3.2 用户维度和Agent执行器维度的双重校验第一层API网关处校验身份。用户登录后拿到一个JWTJWT里包含user_id和role两个字段。后续每次请求都会带这个token后端先解析token再判断该用户是否能访问当前接口。from jose import jwt, JWTError SECRET_KEY your-secret-key def decode_user_token(request: Request): auth_header request.headers.get(Authorization, ) if not auth_header.startswith(Bearer ): raise HTTPException(status_code401, detail未登录) try: payload jwt.decode( auth_header.split( )[1], SECRET_KEY, algorithms[HS256] ) return payload[user_id], payload[role] except JWTError: raise HTTPException(status_code401, detailtoken无效或已过期)JWT本身不存敏感数据只做身份标识。这里的重点是拿到user_id和role之后所有数据查询都必须带上这两个条件不能只靠前端传参。前端传什么都可以伪造但token是后端签名过的相对可信。第二层Agent工具层的行级过滤。这是这个Demo的关键环节。Agent调用工具时工具函数不能只执行“传入什么就返回什么”的逻辑它必须在内部把当前用户身份和数据归属方做一次匹配。我给演示写了一个简化版的数据表结构CREATE TABLE documents ( id INTEGER PRIMARY KEY, title TEXT, content TEXT, department TEXT, owner_id INTEGER ); CREATE TABLE users ( id INTEGER PRIMARY KEY, username TEXT, role TEXT, department TEXT );然后Agent里的read_document工具被调用时会先拿到当前用户的user_id和role然后这样过滤数据async def read_document(user_id: int, role: str, document_id: int): async with db_connection() as conn: if role admin: query SELECT * FROM documents WHERE id $1 else: query SELECT * FROM documents WHERE id $1 AND department ( SELECT department FROM users WHERE id $2 ) result await conn.fetchrow(query, document_id, user_id) return result核心就是一个原则工具函数内部永远不信任传入的参数能代表身份身份必须从调用链的上下文里取数据行必须做归属匹配。3.3 为什么不建议在子请求里直接带token去对接Agent第一次做这个Demo时我的第一反应是Web后端在调用Agent时把用户的JWT原封不动地放到子请求里传给Agent接口Agent内部再解析这个token。后来被mentor否决了理由有三个第一token有效期问题。JWT一般会有过期时间如果用户在操作中途token过期了Agent的子请求也会失败但此时用户明明已经通过了第一层验证。这就会造成体验上的割裂。第二权限放大问题。如果Agent内部把请求转发给其他内部服务带着用户token的请求会在多个服务间传递任何一个服务日志泄露token都会导致整个权限体系被击穿。第三审计问题。Agent的调用链不只一层如果每个层都解析一次token链路上的日志会非常凌乱出了问题根本没法追踪。正确的做法是Web后端在完成第一层认证后不再向Agent传递原始token而是生成一个短期的、最小权限的上下文对象里面只有user_id、role、department这几个业务字段。Agent只认这个上下文不认token。这样就把认证和授权解耦了。最终Demo的数据流是这样的浏览器 → Nginx → FastAPI → 认证中间件解析JWT → 业务接口 ↓ 生成Agent上下文user_id, role, department ↓ Hermes Agent API ↓ 工具函数内部行级过滤 ↓ 数据库3.4 Demo演示效果实测三种提问场景演示的时候我准备了三个测试用例测试场景用户提问/操作结果正常访问本部门数据普通用户A部门研发“查询研发部的文档列表”返回研发部文档正常越权访问其他部门数据普通用户A部门研发“查询市场部的文档列表”返回空结果无报错通过SQL注入绕过普通用户A部门研发“查询所有部门文档包括市场部”工具层过滤后只返回研发部数据第三类场景是演示的亮点。因为Agent工具调用时自然语言会被翻译成SQL如果工具函数里没有强制拼接department条件攻击者的提问就能“逃逸”出数据边界。我在execute_sql工具函数里加了强制条件safe_query query f AND department {user_department}注意这只是Demo写法真实环境必须用预处理参数不能字符串拼接SQL。Demo归Demo安全习惯不能丢。演示效果是三类请求全部符合预期越权访问被静默拦截返回空结果而不是报错全程无敏感数据泄露。4. 踩坑实录这一周最耗时的三个问题完整排查链路任何复盘没有踩坑记录都不完整。这一周我花了大量时间解决三个看似“不该发生”的问题每个都值得单独拿出来说说因为你下个星期大概率也会遇到其中之一。4.1 问题一Agent服务在Nginx代理下频繁404网站部署完成后我用Nginx做了个路由/api/agent的代理把请求转发到Hermes Agent的8080端口。结果不管发什么请求返回全是404。排查链路第一步先绕开Nginx直接在服务器上curl Agent端口curl -X POST http://127.0.0.1:8080/v1/chat/completions \ -H Content-Type: application/json \ -d {model:qwen2.5:7b,messages:[{role:user,content:hi}]}结果正常返回。说明Agent本身没问题问题出在Nginx代理层。第二步看Nginx的错误日志sudo tail -f /var/log/nginx/error.log日志里显示的是connect() to 127.0.0.1:8080 failed (13: Permission denied)。看到这个第一反应是SELinux在搞鬼。但查了一下发现服务器没开SELinux。第三步想到可能是Nginx的http_realip_module还是什么代理权限问题。后来突然意识到我公司在Nginx和Agent之间用的配置里proxy_pass指向的是带路径的URL但Hermes Agent的路由是严格匹配/v1/...的Nginx的location /api/agent/会把路径改写成/api/agent/v1/chat/completionsAgent自然不认识这个路径。解决办法是改写Nginx配置location /api/agent/ { proxy_pass http://127.0.0.1:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }proxy_pass末尾的/会把/api/agent/前缀去掉让转发后的路径变成/v1/chat/completionsAgent就能正确识别了。这个坑和之前部署网站时遇到的斜杠问题一模一样只是换个场景又来了一次彻底记住这个教训了。4.2 问题二本地模型并发一高就内存溢出Agent服务跑通后我做了个简单的并发测试同时发10个请求结果服务直接OOM挂掉。日志显示是Ollama那边的进程内存被吃光。原因分析Hermes Agent本身是异步的能同时接收大量请求但底层Ollama推理模型是串行的每个请求都会加载一批模型权重到显存或内存。并发一高内存就崩了。排查过程先看Agent进程的内存状态ps aux --sort-%mem | head -20看到Ollama的进程内存占用从2G一路涨到6G多然后系统把整个服务OOM-killed了。解决思路不是在Ollama层面加配置而是在Agent入口处做流量控制。我在Hermes Agent外面套了一层简单的信号量限流同时把并发数限制在2from asyncio import Semaphore agent_semaphore Semaphore(2) async def forward_to_agent(message: str): async with agent_semaphore: # 调用 Hermes Agent API ...同时在Nginx层面限制单IP的并发连接数limit_conn_zone $binary_remote_addr zoneagent_conn:10m; location /api/agent/ { limit_conn agent_conn 5; proxy_pass http://127.0.0.1:8080/; }这两层限制叠加后再跑并发测试就稳定了。核心心得异步服务不等于无限并发它只能让你优雅地排队不能让你同时处理超出底层资源上限的请求。4.3 问题三Agent工具调用的临时文件权限异常Aagent在调用read_document工具时需要把某个PDF临时复制到临时目录再解析。结果工具一直报PermissionError但奇怪的是手动在服务器上执行同样的命令却完全正常。排查链路第一步在Agent日志里看到完整报错File /tmp/hermes_tmp_xxx.pdf, line 1: PermissionError: [Errno 13] Permission denied第二步检查临时目录权限ls -ld /tmp结果drwxrwxrwt 16 root root 420 ...这是标准tmp目录权限没问题。第三步想到问题出在systemd的PrivateTmp字段。systemd默认会为服务创建独立的命名空间服务进程看到的/tmp和系统真正的/tmp不是一个目录。如果你用PrivateTmptrue或者分配了这个默认值Agent服务写入的临时文件只会存在于它的私有tmp中但Ollama或者别的工具进程可能访问不到那个私有目录。我在之前部署网站的unit文件里把PrivateTmpfalse显式设上了但Hermes Agent的服务单元文件是Hermes安装包自己生成的默认是PrivateTmptrue。解决办法在Hermes Agent的systemd服务文件里加一行PrivateTmpfalse然后重启服务。问题消失。这个坑很隐蔽因为报错信息看起来像是权限问题实际却是systemd的命名空间隔离机制。遇到PermissionError先去查systemd配置比在文件系统权限上钻牛角尖高效得多。5. 五周实习的一些体会先跑通流程再谈优化五周时间说长不长说短不短。到第五周结束我已经能独立完成“部署一个服务、接入一个Agent、设计一套权限链路”这样的端到端任务了。回想这几周踩过的坑我觉得最值钱的经验有几条。第一选型永远先看环境再谈技术。Docker很好但公司规范要求systemd那就别在规范之外秀技术。Hermes Agent是开源项目但真正决定能不能用的是它对MCP的支持和模型接入方式。技术选型没有普适答案永远是在约束条件下找最优解。第二日志是排查问题的唯一真相。这周所有的坑最终都是靠日志定位的。Nginx的问题看error.logsystemd的问题看journalctlAgent的问题看Hermes的日志输出。遇到问题先看日志别猜猜永远比看慢。第三权限隔离不能只做前端也不能只做后端要贯穿整条数据链路。页面按钮可以隐藏但真正的数据访问控制必须在API层和Agent工具层各做一次。这个意识如果能在实习生阶段就建立起来对后面的职业发展会是很大的帮助。第四Agent的权限设计比传统Web应用的权限设计更复杂因为自然语言本身就是一个逃逸通道。用户在界面上看不到某个按钮不代表他不能通过Agent的对话把数据“绕”出来。所以Agent接入任何系统的时候工具层的行级过滤不是可选项而是必选项。第五周干完我对自己的定位清晰了很多实习生不是来写某个函数的而是来理解整个系统怎么转的。下一周的计划是优化Agent的使用体验——让它能更准确地调用内部知识库的搜索服务同时把MCP服务的注册流程整理成文档方便组里的同事直接复用。这个内容后续还可以扩展成一套标准化的工具部署流程到时候再单独写一篇完整的手册出来。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门