实习第五周,任务量突然就上来了。前四周我一直在熟悉代码库、改小功能、补测试,这周直接给我安排了三件事:把一个内部工具站部署到测试服务器、在公司内网环境里配置一套本地Agent、再基于这套东西做一个权限隔离的Demo。从结果看,这三件事其实是同一条链路——网站负责入口,Agent负责能力,权限隔离负责边界。这篇就当周记复盘,把部署流程、选型理由、踩坑过程都写清楚,给后面接这个活的同学留个参考。
先说结论:整个过程花了三个整天加两个晚上,网站部署和Agent配置本身不算难,真正磨人的是权限隔离那个Demo。因为Agent一旦能调用工具,它的权限边界就不只是“谁能访问这个页面”,而是“这个请求能替用户执行到什么程度”。这个抽象问题如果不落地成具体的校验链路,Demo做出来也是花架子。
1. 网站部署:为什么我没用Docker,而是选择systemd加Nginx
我接手的是一个内部小工具站,后端是Python写的,提供一堆查询接口,前端就几个页面。按我平时的习惯,这种服务直接Docker Compose一套带走最省事。但看了一眼测试服务器的环境,我放弃了。
服务器是台老机器,内核版本偏低,Docker装倒是能装,但跑起来性能损失明显。更关键的是,公司内部有统一的部署规范,测试环境要求服务直接挂在systemd下,由运维的监控脚本统一拉取状态。这个背景下,我与其再套一层容器,不如直接按规范来。
1.1 部署方案选型时我在想什么
当时摆在面前的有两套方案:
| 方案 | 优点 | 缺点 |
|---|---|---|
| Docker Compose | 环境隔离好,复现容易 | 内网环境镜像拉取受限,老内核有兼容性隐患,不符合组内规范 |
| systemd + Gunicorn + Nginx | 符合公司规范,运维监控直接可用,故障排查链路短 | 依赖管理要自己来,升级要手动处理 |
我选了后者。说实话,如果你在个人服务器上部署,那Docker依然是首选;但如果你也是在公司内网、老服务器、有统一运维规范的环境下做事,跟着规范走永远比秀技术重要。这个选择没有对错之分,只有环境适配的问题。
依赖管理我用的是uv,Python 3.11的虚拟环境。用uv而不是pip直接装,是因为它在依赖解析和安装速度上明显更快,而且会把依赖版本锁死在一个uv.lock里,后面服务重新部署、依赖回滚都很方便。我甚至在这个环节踩过一个坑——最初直接用pip install -r requirements.txt装,结果Passion装了个较高的版本,接口返回的数据结构变了,前端直接白屏。后来改成uv加锁文件,才把版本一致性保住。
1.2 systemd服务单元文件怎么写才不容易出问题
Gunicorn作为WSGI服务器来启动Python应用。这里我建议不要用Flask自带的开发服务器,那个单进程、无并发保护,线上环境撑不住。
下面是我最终用的service文件,关键参数都加了注释:
[Unit] Description=Internal Tool Web Service After=network.target [Service] User=www-data Group=www-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 Restart=always RestartSec=3 PrivateTmp=false [Install] WantedBy=multi-user.target几个容易踩的细节:
EnvironmentFile指向.env文件,数据库连接串、密钥、外部API地址都放这里,而不是直接写死在代码里。这样环境切换时只需要换文件,不需要改代码。User我特意用了www-data而不是root,避免服务进程权限过大。PrivateTmp=false这个字段,后面在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服务注册。MCP(Model 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 python=3.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(timeout=120) 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. 权限隔离Demo:RBAC模型与Agent调用链的边界设计
权限隔离Demo是这周最耗脑子的任务,核心需求一句话:平台上有多个部门的数据,用户A登录后,只能查询自己部门的数据;即使用户A通过Agent提问“把B部门的数据给我导出来”,系统也要拦得住。
3.1 Demo要证明什么,不证明什么
先理清需求边界,很多权限隔离Demo做砸了是因为没想清楚要证明什么。
我的理解是,这个Demo要证明两件事:
- 不同用户访问Web API时,后端能根据其身份返回不同的数据视图。
- 不同用户向Agent提问时,Agent的工具调用链路能感知到用户身份,并在读取数据时做行级过滤。
不需要证明的:Agent模型本身有多聪明、回答有多自然。这些和权限隔离无关。
基于这个理解,我设计了两层校验:第一层在API网关,负责做身份认证和粗粒度权限判断;第二层在Agent工具内部,负责做数据行级过滤。
3.2 用户维度和Agent执行器维度的双重校验
第一层:API网关处校验身份。用户登录后拿到一个JWT,JWT里包含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_code=401, detail="未登录") try: payload = jwt.decode( auth_header.split(" ")[1], SECRET_KEY, algorithms=["HS256"] ) return payload["user_id"], payload["role"] except JWTError: raise HTTPException(status_code=401, detail="token无效或已过期")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/completions,Agent自然不认识这个路径。
解决办法是改写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/completions,Agent就能正确识别了。这个坑和之前部署网站时遇到的斜杠问题一模一样,只是换个场景又来了一次,彻底记住这个教训了。
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外面套了一层简单的信号量限流,同时把并发数限制在2:
from 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 zone=agent_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不是一个目录。如果你用PrivateTmp=true(或者分配了这个默认值),Agent服务写入的临时文件只会存在于它的私有tmp中,但Ollama或者别的工具进程可能访问不到那个私有目录。
我在之前部署网站的unit文件里,把PrivateTmp=false显式设上了,但Hermes Agent的服务单元文件是Hermes安装包自己生成的,默认是PrivateTmp=true。
解决办法:在Hermes Agent的systemd服务文件里,加一行:
PrivateTmp=false然后重启服务。问题消失。
这个坑很隐蔽,因为报错信息看起来像是权限问题,实际却是systemd的命名空间隔离机制。遇到PermissionError先去查systemd配置,比在文件系统权限上钻牛角尖高效得多。
5. 五周实习的一些体会:先跑通流程,再谈优化
五周时间说长不长,说短不短。到第五周结束,我已经能独立完成“部署一个服务、接入一个Agent、设计一套权限链路”这样的端到端任务了。
回想这几周踩过的坑,我觉得最值钱的经验有几条。
第一,选型永远先看环境再谈技术。Docker很好,但公司规范要求systemd,那就别在规范之外秀技术。Hermes Agent是开源项目,但真正决定能不能用的是它对MCP的支持和模型接入方式。技术选型没有普适答案,永远是在约束条件下找最优解。
第二,日志是排查问题的唯一真相。这周所有的坑,最终都是靠日志定位的。Nginx的问题看error.log,systemd的问题看journalctl,Agent的问题看Hermes的日志输出。遇到问题先看日志,别猜,猜永远比看慢。
第三,权限隔离不能只做前端,也不能只做后端,要贯穿整条数据链路。页面按钮可以隐藏,但真正的数据访问控制必须在API层和Agent工具层各做一次。这个意识如果能在实习生阶段就建立起来,对后面的职业发展会是很大的帮助。
第四,Agent的权限设计比传统Web应用的权限设计更复杂,因为自然语言本身就是一个逃逸通道。用户在界面上看不到某个按钮,不代表他不能通过Agent的对话把数据“绕”出来。所以Agent接入任何系统的时候,工具层的行级过滤不是可选项,而是必选项。
第五周干完,我对自己的定位清晰了很多:实习生不是来写某个函数的,而是来理解整个系统怎么转的。下一周的计划是优化Agent的使用体验——让它能更准确地调用内部知识库的搜索服务,同时把MCP服务的注册流程整理成文档,方便组里的同事直接复用。这个内容后续还可以扩展成一套标准化的工具部署流程,到时候再单独写一篇完整的手册出来。