Sim开发日记

前言

要学的基础内容都已经过完了,接下来得上手做了,知行合一,以致良知。

先从简单的issue开始。

请求打进到得到响应的链路

以用户登录为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
┌─ 文件1: username_login_handler.go ──────────────────────────┐
│ HTTP 入口,解析 JSON → 调用文件2 │
│ l := auth.NewUsernameLoginLogic(ctx, svcCtx) │
│ resp, err := l.UsernameLogin(in) // → types.LoginResp │
└──────────────┬───────────────────────────────────────────────┘
│
┌─ 文件2: cmd/app/.../username_login_logic.go ─────────────────┐
│ HTTP Logic,类型转换(types.UsernameLoginReq → pb.UsernameLoginReq)│
│ rpcResp, err := l.svcCtx.AuthClient.UsernameLogin(...) │
│ return &types.LoginResp{Token: rpcResp.Token, ...} │
└──────────────┬───────────────────────────────────────────────┘
│ 通过 AuthService 接口调用
┌─ 文件3: auth_service.go (client 包装) ───────────────────────┐
│ defaultAuthService.UsernameLogin() │
│ → pb.NewAuthServiceClient(m.cli.Conn()).UsernameLogin() │
│ → 序列化请求,通过 gRPC 发到 auth-rpc 服务端 │
│ → 返回值类型: *pb.LoginResp │
└──────────────┬─── gRPC 网络 ───┬─────────────────────────────┘
│ │
┌─ 文件4: auth_service_server.go ──────────────────────────────┐
│ gRPC Server 骨架,反序列化 → 调用文件5 │
│ l := authservicelogic.NewUsernameLoginLogic(ctx, s.svcCtx) │
│ return l.UsernameLogin(in) // → *pb.LoginResp │
└──────────────┬───────────────────────────────────────────────┘
│
┌─ 文件5: username_login_logic.go (核心实现) ───────────────────┐
│ 查数据库 → 验密码 → 生成 JWT │
│ return &pb.LoginResp{UserId: ..., Token: ..., Role: ...} │
└──────────────────────────────────────────────────────────────┘

关键观察:文件3中 pb.NewAuthServiceClient(m.cli.Conn()).UsernameLogin() 返回的就是 (*pb.LoginResp, error)——而文件5恰好也返回 (*pb.LoginResp, error)。这不是巧合,是因为 gRPC 框架通过 proto 文件保证了客户端存根和服务端骨架使用相同的消息类型。文件3的客户端存根和服务端文件4+5用同一个 pb.LoginResp,所以链路两端的类型天然对齐。

context.context与svc.ServiceContext区分

第一个ctx是请求中的ctx,每个请求都会新建一个ctx,存储uer_id, role, token;
第二个ctx是每个服务的依赖组装ctx,它会被main创建全进程唯一实例,然后把这个ctx传给所有server,如mall中的订单server和商品server,然后下游的logic通过*引用这个实例,把它存为自己的字段,然后就可以通过这个字段访问里面的各个依赖如redis、db

uuid生成器

为什么要设计这个包:
传统数据库的主键一般是自增的123456,在分布式的环境下容易冲突,uuid全局唯一,永不冲突,而且还带表名前缀,易辨识。

这是肖佬给我派的第一个开发任务,开发uuid基层设施,让每个model表能够生成自己的唯一主键。

我是用Codex写的代码,肖佬给的答复是写的过于复杂,其实也确实。我自己是能理解的,但是让新人来理解确实要费神些,这种小小的中间件写简单的就行了。所以说,写简单的东西用太厉害的AI也许有失偏颇。

肖佬写的很简单,就是下游提供三个通用函数,分别是标准uuid、编码后22位uuid、带前缀uuid。然后这三个函数也写的很简答,一两行可以搞定,不用搞什么写写一个生成器函数,然后再用生成器函数生成生成uuid的函数。

然后logic调用层就是直接引入infra/uuid包,然后直接调用函数得到结果。不需要什么BeforeCreate自动补主键钩子,直接显示赋值就行,不能过度设计了。

为服务添加小功能的步骤

为了学习go-zero的api语法和proto语法,我自行为auth服务新增了个小功能——登出功能。下面介绍实现步骤。覆盖了从网关的.api文件到完成服务的全流程。

这一段很重要,是了解全局开发流程的关键:

我来说说我学到的从接收登出请求到响应登出请求的流程:我的目的是在auth服务中加入登出的功能,然后我先在.api文件中新增一个登出服务并加上了中间件(由于其它鉴权服务不依赖中间件但这个依赖,所以在网关层它从鉴权服务中独立出来),然后用goctl api的那个命令重新生成了gozero骨架(但是原有写好的功能不会消失),然后我再去写rpc服务的那个目录下的proto文件中新增登出服务,然后用goctl rpc protoc那个命令生成rpc服务中的gozero骨架(同样其它服务不会被删除)。然后我得手写网关层和rpc层的logic函数。也就是先写rpc服务的函数,这个函数接收pb中的LogoutReq结构体,按理说应该用redis黑名单功能验证token,但是这里我简写为直接通过了验证,再返回pb中的LogoutResp结构体。然后写网关层的logic,这个函数从中间件中拿到token后,把token作为参数传入pb中的LogoutReq结构体参数,然后返回刚刚写的rpc函数的pb中的LogoutResp结构体。

我悟道了几点:

1、网关层和rpc层用pb传输信息,二者都不知道对方的存在,并且二者的服务分类方式也不同,前者注重HTTP 层面需求,如路由前缀、中间件、标签, 后者注重业务领域,即功能内聚性。
2、我要写的也就是两个logic,其它的会自动生成我也不需要修改。
3、改掉网关层的服务分组模式后,我会用goctl生成新的代码,但是旧分组时的代码需要我手动删除。

这个任务我保存在了本地分支:feat/logout/hfs,但是还没完整实现,还差具体的token验证逻辑。

中间件

我先要在auth的.api文件中的userserver组中写上中间件,也就是获取用户信息的路由绑定了一个中间件,然后在goctl api命令生成的routes.go文件中查看到user/info的路由是有个中间件的,此时我还需要写authmiddleware的实现代码,也就是app/middleware/auth_middleware.go,这个文件主要为svc/serviece_contex.go服务,中间件构造函数需要用到AuthService接口(这是直接导入了client然后直接用了,rpc的client也确实是这么用的),然后返回AuthMiddleware结构体,然后给这个结构体实现Handle函数,在Handle函数中,我写了具体的token验证逻辑,然后把user_id等信息注入contex中,然后这个ctx中的user_id等信息就可以被网关层的 logci层的函数使用或者通过proto显示传递给rpc层的logic层函数使用。在这个过程中,ServiceContext 调用auth_middleware.go 的构造函数,把 RPC 客户端注入进去,生成中间件并存入自身,供routes.go注册。该中间件拦截 HTTP 请求、验证 token、将 user_id 注入 context。网关 Logic 层从 context 取 user_id,再通过 proto 字段显式传给下游 RPC 服务。中间件只在网关层生效,不到 RPC 层。

这里可以注意到,rpc层提供的下游服务,是可以直接通过client被上层网关层导入然后直接用的,唯一中间层(网关层的svc)只是把把网关层的中间件和rpc层的客户端集中起来,避免logic包重复创建,毕竟logic包很多地方都用上了ctx中的东西

读写文件tool

只是简单的在已有的tools工具集中拓展读写文件的tool,这个tools就是agent在执行任务中,可以选择性调用的工具,其它两个工具分别是对商品海选的searchProducts以及对选出来的商品进行筛选的selectBundle。

agent沙箱

背景

LLM 工具已支持 read_file / write_file,但当前直接基于路径读写文件,存在越权读写风险。

任务

  • 增加 Agent 文件工具工作目录配置,例如 workspace/agent。

    限制 read_file / write_file 只能访问工作目录内文件。

    拒绝 ..、绝对路径、软链逃逸等路径。

    限制单文件最大读取大小。

    限制可写文件后缀,例如 .json、.md、.txt。

    增加文件工具单测。

    更新 LLM prompt 中关于文件路径的说明。

验收标准

  • 读取 ../../.env 被拒绝。

    写入工作目录内 recommendations/demo.md 成功。

    超大文件读取返回明确错误。

    文件工具测试覆盖正常路径和拒绝路径。

面试回答

1、请你先用两分钟介绍这次任务的背景、原有实现存在什么风险,以及你的整体解决方案。
答:原来的 read_file 和 write_file 会直接使用 LLM 提供的路径调用 os.ReadFile 和 os.WriteFile。由于模型输入不可信,可能通过绝对路径、.. 路径穿越或软链接读取 .env 等敏感文件,甚至覆盖服务配置和源码。

我的方案是增加应用层受限工作目录,所有文件路径必须是该目录下的相对路径。同时拒绝绝对路径和 ..,解析软链接后再次检查真实路径是否仍在工作目录内。此外还限制了单文件读取大小和可写文件后缀,并补充了对应的安全测试。

2、只把用户路径和工作目录用 filepath.Join 拼接起来,为什么仍然不安全?你是如何防止路径逃逸的?
答:filepath.Join 会自动清理路径,因此将工作目录和 ../../.env 拼接后,结果可能已经位于工作目录之外。即使拒绝了 ..,攻击者仍可能通过软链接跳转到外部目录。

我的实现先进行词法校验,拒绝空路径、空字节、绝对路径、Windows 盘符和任何 .. 路径段,同时统一处理 / 和 \。之后使用 filepath.EvalSymlinks 解析真实路径,再通过 filepath.Rel 判断目标是否仍然位于工作目录内。

3、EvalSymlinks 要求目标路径已经存在,但 write_file 需要创建新文件。对于尚不存在的写入目标,你是怎么检查软链接逃逸的?
答:新文件本身不存在,所以不能直接对目标文件执行 EvalSymlinks。我的处理方式是从工作目录开始,逐级检查目标文件的父目录。

每一级目录都通过 Lstat 判断类型。如果目录不存在,就在已经验证安全的父目录下创建;如果是软链接,就解析真实目标并确认它仍在工作目录内;如果不是目录,则拒绝访问。父目录全部验证通过后,再创建目标文件。如果目标文件已经存在且是软链接,还会单独解析并检查它的真实目标。

4、为什么读取文件时既检查了 Stat().Size(),又使用 io.LimitReader 限制实际读取量?只做其中一个不行吗?
答:Stat().Size() 用于在读取前快速拒绝已经超过限制的文件,避免不必要的 I/O 和内存分配。但文件可能在 Stat 检查完成后或读取过程中继续增长,所以不能只依赖文件元数据。

实际读取时再使用 io.LimitReader(maxReadBytes+1) 设置硬上限。多读取一个字节是为了判断文件是否真正超过限制。这样既能快速失败,也能防止检查和读取之间文件内容发生变化。

5、既然已经在 Prompt 中告诉模型不要使用绝对路径和 ..,为什么代码中还需要进行严格校验?
答:Prompt 对模型的限制是概率性的,不能作为安全边界。模型可能理解错误,也可能受到用户恶意输入或 Prompt Injection 的影响,因此模型生成的工具参数必须被视为不可信输入。

Prompt 的作用是引导模型正确使用工具,而代码的作用是强制执行安全策略。即使模型违反 Prompt,底层文件操作也必须拒绝非法路径。

6、write_file 为什么要限制文件后缀?具体实现时,如何处理 .JSON 这种大小写形式,以及 report.md.exe 这种多重后缀?
答:限制写入后缀是为了遵循最小权限原则。当前业务只需要保存用户偏好、推荐报告和普通文本,没有必要允许模型写入 Go 源码、Shell 脚本、服务配置或可执行文件。

实现时会将配置和输入后缀统一转换成小写,因此 .JSON 可以匹配 .json。文件后缀通过 filepath.Ext 获取最后一段,所以 report.md.exe 得到的是 .exe,不会因为中间包含 .md 而绕过白名单。

7、为什么没有把所有安全检查继续写在 llm/tools.go 里,而是单独新增了 internal/filetools 包?这样设计有什么好处?
答:llm/tools.go 的职责应该是定义 Eino 工具、注册 handler,并将工具结果返回给模型,不应该同时承担复杂的文件系统安全逻辑。

将路径校验和文件读写抽到 internal/filetools 后,可以降低 Agent 编排和文件安全策略之间的耦合。以后修改工作目录、读取上限或软链接检查时,只需要修改这个包。同时可以直接针对 Workspace 编写单元测试,不需要启动 LLM 或构造完整的 ReAct Agent。

8、FileTools 配置是如何从 config.yaml 一直传递到 read_file / write_file handler 的?为什么还要提供默认值?
答:config.yaml 首先被加载到 internal/config.Config.FileTools,然后 ServiceContext 在创建 LLM Agent 时将 c.FileTools 传给 llm.NewAgent。NewAgent 使用该配置创建 filetools.Workspace 并保存在 Agent 中。每次运行时,Agent 将 Workspace 传给 businessTools,最终由 read_file 和 write_file 的 handler 闭包使用。

提供默认值是为了让旧部署在没有新增配置时仍然可以正常启动,同时默认就是安全状态。默认工作目录是 workspace/agent,最大读取大小是 1 MiB,可写后缀为 .json、.md 和 .txt。如果用户显式提供了无效配置,则应该尽早失败,而不是放开限制。

9、你为文件工具设计了哪些测试?为什么这些测试能够证明正常路径可用,同时主要攻击路径会被拒绝?
答:正常路径测试会向 recommendations/demo.md 写入内容,检查写入字节数,然后重新读取并验证内容一致,证明工作目录内的正常业务功能可用。

拒绝路径测试覆盖了 ../../.env、../outside.txt、嵌套 ..、Linux 绝对路径和 Windows 盘符路径。此外还测试了非法写入后缀、超大文件读取和软链接逃逸。测试不仅断言这些操作返回错误,还会在软链接写入失败后检查工作目录外没有产生文件,从而验证没有外部副作用。

10、当前实现是否已经绝对安全?还存在哪些残余风险?如果安全等级进一步提高,你会怎么改进?
答:不存在绝对安全。当前方案已经降低了路径穿越和软链接逃逸风险,但路径检查和真正写入之间仍然存在 TOCTOU 竞争窗口。另外,目前只限制了读取大小,还没有限制单次写入大小、文件数量和工作目录总容量。

当前工作目录也是服务级共享目录,没有按用户或会话隔离。后缀白名单只能检查文件名,不能证明文件内容安全。如果需要进一步提高安全等级,可以使用 Linux 的 openat、目录文件描述符和 O_NOFOLLOW 原子打开文件,增加写入大小和磁盘配额,并按用户或会话创建独立工作目录。部署层还可以使用低权限账号或容器挂载进行隔离。

11、这个 PR 会带来哪些兼容性变化?Merger 在审核和上线时最需要关注什么?
答:这个 PR 会收紧原有文件工具行为。以前可以访问任意进程可访问路径,现在只能访问 FileTools.Workspace 下的相对路径。绝对路径、.. 和软链接逃逸都会失败。超过 1 MiB 的文件默认无法读取,写入默认只允许 .json、.md 和 .txt。

例如工作目录是 workspace/agent 时,模型应该传 recommendations/demo.md,不能传 workspace/agent/recommendations/demo.md。Merger 需要确认默认工作目录是否符合服务实际启动目录、运行用户是否有写权限、业务是否需要更大的读取上限或其他文件后缀。多实例部署时还需要注意本地工作目录默认不共享。

12、项目要求“不要过于复杂化”。你的实现新增了独立包、配置和较多路径检查。你如何说明这不是过度设计?
答:这次新增的复杂度都有明确需求或对应的攻击风险,不是为了未来可能出现的场景提前设计。

独立 filetools 包用于隔离安全职责和提高可测试性;配置通过项目已有的 Config → ServiceContext → Agent 链路注入,没有引入新的配置体系;不同路径检查分别对应绝对路径、..、跨平台路径和软链接逃逸,并不是重复校验。

同时我也控制了实现范围,没有引入完整虚拟文件系统、复杂权限模型、第三方依赖或 Linux 专属的 openat 封装。当前方案主要使用 Go 标准库,在安全性、可维护性和实现成本之间做了适合项目当前阶段的取舍。

request封装

背景

目前项目中用户身份和请求信息主要通过 context.Context 传递,包括:

  • 用户 Token
  • 用户 ID
  • 用户角色
  • Authorization 等请求头
  • Request ID、客户端 IP、User-Agent 等业务上下文

现有代码通常直接调用 ctx.Value("user_id")、ctx.Value("token") 获取数据,存
在以下问题:

  1. Context Key 和类型断言分散,容易出现拼写或类型错误。

  2. 各业务模块需要重复编写解析逻辑。

  3. 后续增加请求级字段时,需要修改多个业务模块。

  4. 直接使用字符串作为 Context Key 容易产生冲突。

这个任务主要目的是让各拦截器,中间件等在读取ctx中的目标字段时不用自己传key,因为自己容易写错,所以request包提供统一存取ctx中变量的方法。

首先我得明确,infra下的拦截器是给grpc用于验证的,cmd中的中间件是给HTTP用于验证的,二者的验证互不相关。

信息注入Context和从Context中读取信息有说法的。

每次业务(拦截器、中间件等)要往Contex中注入时,会先克隆一份Context,然后往克隆体中写,每次读也是从最后的克隆体中找,找不到再往之前的找。
然后这里就存在一个私有的contextKey,用于在Context中存有request信息的一个唯一标识。

我不得不再提一下server拦截器(auth_interceptor)和clietn拦截器(client_interceptor),它们在grpc的请求链路中职责不同,client只负责把当前ctx信息注入metadata(搬运),而server拦截器负责验证身份信息,然后把身份信息注入ctx

面试回答

1. 为什么要封装 infra/request,只使用强类型 Context Key 不够吗?

标准答案:

原来的问题不只是字符串 Key 可能冲突,还包括请求字段分散、类型断言重复

包内私有的强类型 Key 确实可以解决 Key 冲突,但如果 UserID、Token、Role 仍然使用多个独立 Key,业务代码还是需要分别读取和断言,新增字段时也要修改多个地方。

2. 如何保证 Context 中的 Request 不会被业务代码修改?

标准答案:

我使用了防御性复制。

NewContext 注入时不会直接保存调用方传入的指针,而是先复制一份;FromContext 读取时也不会返回内部指针,而是再次返回副本。

另外,只复制结构体还不够,因为 Headers 是 map,结构体浅拷贝后仍然共享同一个底层 map,所以还需要单独复制 Header map。

最终通过“注入时复制、读取时复制、map 深拷贝”,保证调用方修改原对象或读取结果时,都不会影响 Context 内部保存的数据。这里实现的是防御性不可变。

3. 为什么请求解析层不直接校验 JWT?

标准答案:

我把 Token 提取和 Token 校验分成了两个职责。

这样 infra/request 不需要依赖密钥和认证配置,可以降低耦合,也避免 HTTP 和 gRPC 重复实现 JWT 校验。

还有一个重要点是,UserID 和 Role 不能直接信任客户端 Header,而应该在 Token 校验成功后,使用认证结果写入 Request,保证身份信息来自可信链路。

4. 为什么请求头必须使用白名单,不能全部透传?

标准答案:

HTTP Header 中可能包含 Cookie、Session、内部鉴权信息和代理字段。如果全部保存并自动传给下游,会扩大敏感信息传播范围,也可能让客户端伪造内部 Header。

因此我采用最小白名单,只保存业务明确需要的字段,向下游主要透传 Authorization 和 Request ID。UserID、Role 不从客户端 Header 透传,而是由下游重新验证 Token。

对重复 Authorization,我选择直接拒绝。因为不同代理或框架可能分别取第一个、最后一个或合并值,导致各层校验的 Token 不一致。

gRPC 写入 metadata 时使用 Set 而不是不断 Append,确保多次经过拦截器后也只有一个 Authorization。

memory包

前言

由于看许长勇写的那个被我接的issue,让我更通透的了解到了memory包,也许我可以在简历上说这个包是我开发的,嘻嘻

描述

负责推荐 Agent 的会话记忆模块设计与实现。解决 LLM 在多轮对话中无法感知历史上下文的痛点,设计接口-实现分离的记忆管理层,支持 Redis 分布式存储与进程内降级,并通过读写分离确保 LLM/规则双链路在降级场景下的历史完整性。

为什么要这个包

用户与agent的对话需要上下文,不然用户第二轮对话说“把刚才那个键盘换成好一点的”,agent就不知道啥意思。

需求 说明
多轮对话 LLM 需要通过历史理解指代、比较、替换等上下文依赖
降级不丢历史 LLM 失败走规则兜底后,下一轮 LLM 恢复了也要看到完整上下文
跨请求持久化 HTTP 是无状态的,同一用户两次请求间需要外部存储桥接

怎么加进去(只加入LLM链路)

接口先行——只暴露三个方法

1
2
3
4
5
6
// memory/memory.go
type Manager interface {
Append(ctx, conversationID, msgs...) // 追加消息
History(ctx, conversationID, limit) // 读最近 N 条
Clear(ctx, conversationID) // 清空会话
}

刻意克制——不要会话元数据、不要按角色过滤、不要分页。推荐场景只需要”追加问答对、读取窗口、清空”,多一个方法都是过度设计。

双实现——生产/开发自动切换

Manager 接口实现(二选一)
├── InMemory ← 没配 Redis 时用,进程内 map,重启丢失
└── Redis ← 配了 Redis 用,跨实例共享,AOF 持久化,TTL 自动过期

两个实现共享同一套 [encodeMessage](vscode-file://vscode-app/d:/韩/下载的软件/vscode/Microsoft VS Code/e4c7e7b1d6/resources/app/out/vs/code/electron-browser/workbench/workbench.html)/[decodeMessage](vscode-file://vscode-app/d:/韩/下载的软件/vscode/Microsoft VS Code/e4c7e7b1d6/resources/app/out/vs/code/electron-browser/workbench/workbench.html)(JSON 编解码),所有行为用例在两个实现上交叉测试,保证语义一致。

依赖注入

在service_context中New一个Agent的时候,把memory加进去

简历具体写:

• 设计多轮对话记忆管理模块,抽象 3 方法 Manager 接口,
基于策略模式提供 Redis/InMemory 双实现,支持配置驱动的自动降级

• Redis 实现采用 Pipeline 原子写入(RPUSH + LTRIM + EXPIRE),
保障消息追加、窗口截断、TTL 刷新的语义一致性;
消息窗口强制对齐偶数,避免跨 QA 对截断

• 采用读写分离架构:编排层统一写记忆,LLM Agent 单侧读历史,
规则兜底链路不依赖记忆,确保降级路径历史无空洞

• Key 设计为 userID + conversationID 复合维度,实现用户级数据隔离;
编解码层通过 JSON 反序列化保证深拷贝语义,防止指针污染

• 基于 miniredis 实现双实现交叉测试,所有行为用例跨 InMemory/Redis 一致验证

问答

追问 应答方向
为什么只有 3 个方法? 推荐场景不需要分页、元数据、角色过滤,克制设计避免过度抽象
History 失败了怎么办? 返回空切片不报错——历史是增强信息,不是必需输入,降级为单轮推荐
怎么保证降级不丢历史? 写入在 Service 层(不在 Agent 内),无论 LLM 成功还是规则兜底都统一写入
消息存了什么? 只存原始 Query + 最终 Summary,工具调用细节不记忆,避免污染上下文窗口
InMemory 和 Redis 行为不一致怎么办? 同一套测试用例在两套实现上交叉跑,[miniredis](vscode-file://vscode-app/d:/韩/下载的软件/vscode/Microsoft VS Code/e4c7e7b1d6/resources/app/out/vs/code/electron-browser/workbench/workbench.html) 模拟真实 Redis 环境
Icon
致谢名单
本作品由 Hafsun 于 2026-07-16 13:17:11 发布
作品地址:Sim开发日记
除特别声明外,本站作品均采用 CC BY-NC-SA 4.0 许可协议,转载请注明来自 欢迎回家
Logo
上一篇太阳雨下一篇修成正果