深浅色
EinoTalk 追问
面试官可能追问的 22 个问题 + 我的答案。答案里的数字和文件名都能在仓库里查到,别说没把握的。
Agent 与流式
Q:为什么用 CloudWeGo Eino,不直接调 OpenAI SDK 或 LangChain? A:Eino 是 Go 生态的 LLM 编排框架,和项目的 Go 后端同语言,不用为一个 Agent 引入 Python 侧车进程。它原生提供 ReAct Agent、Tool 抽象和 Stream 接口,我需要的「多步推理 + 工具调用 + 流式」三件事都在一个库里。LangChain 生态在 Python/JS,Go 服务集成要跨进程;裸调 SDK 则要自己实现 ReAct 循环、工具 schema 和重试,属于重复造轮子。代价是 Eino 版本还比较新(0.9.x),API 有变动风险,所以我做了 Mock 回退来解耦。
Q:ReAct 的 MaxStep 为什么是 8?调大调小会怎样? A:8 是「够用」和「可控」的平衡点。我的工具只有 4 个,典型链路是 search -> 总结,2 到 3 步就结束;8 步足够覆盖「搜索失败换关键词再搜」这种重试场景。调小会截断多步推理,调大则一旦模型陷入循环,单次请求的 LLM 成本和耗时线性上涨。真正的兜底不是 MaxStep,而是 25s 超时 + 背压,MaxStep 只是第一道闸。
Q:流式为什么要做 80ms / 32 字符聚合?只按时间聚合行不行? A:只按时间聚合的问题是在低速场景下会「憋字」——模型 200ms 才吐 3 个字,用户看着卡顿;只按字符数聚合则在高并发下会高频推帧。所以用双阈值:任一满足就 flush。80ms 是人眼感知不到的上限,32 字符是一帧的合理体积。实测帧数比逐 token 推低一个数量级。代价是首字延迟最多增加 80ms,这个数字我写在文档里,避免后人误改。
Q:done 帧才落库,流到一半用户断线了怎么办? A:当前实现里,如果连接在流式过程中断开,done 帧推不出去,这条 AI 回复就不落库——用户重连后看不到半截回复。这是一个已知取舍:好处是不会在数据库里留下「残缺的 AI 回复」,坏处是 LLM 已经消耗的 token 白费了。更完整的做法是在 Agent 侧独立完成落库(不依赖 WS 送达),我把它列在改进项里。
Q:三级降级怎么判断该降哪一级? A:按能力从高到低尝试,失败就往下走。第一级 StreamWithTools 需要模型支持 function calling,且 Eino 的工具编排初始化成功;第二级 Stream 只要模型支持流式;第三级 Generate 是纯一次性生成。另外还有一层:如果 Eino 整体初始化失败(比如配置缺失),直接回退到 Mock provider,保证服务不 500。
Q:Mock 模式是什么?为什么要有? A:Mock 是一个实现了同样接口的假 provider,返回固定/规则化的回复。有三个用处:本地没有 API Key 也能完整演示链路;CI 里跑集成测试不烧钱、不依赖外网;LLM 供应商故障时服务不整体挂掉。这是「可测试性」设计,不是偷懒。
Q:Agent 怎么防止用户伪造别人身份去读别人的聊天记录? A:两层。第一,Client.Read() 里强制把 message.SendId 覆写成连接握手时的 c.Uuid,客户端传什么身份都不作数。第二,构建上下文时按场景限定范围:私聊 Agent 只读「当前用户与 Agent 自己」的历史,群聊 Agent 只读当前群的历史,绝不做全局检索。工具 search_chat_history 也继承同样的作用域。
Q:群聊里 Agent 会不会被刷屏?怎么防? A:必须显式触发才回应:只有消息命中 @AI助手 / @agent / /ai 前缀才进入 Agent 分支,且触发词会被剥离后再传给 LLM,避免模型把 @AI助手 当成内容复述。未被触发的群消息一律不主动发言。另外 Agent 调用走独立协程 + 429 背压,不会因为群消息量大而拖垮主链路。
Q:滚动摘要的 5min 节流锁是干什么的? A:摘要生成本身要调一次 LLM,如果每条消息都触发,成本和延迟都不可接受。所以摘要结果写 Redis 缓存 1 小时,并用一个 5 分钟的节流锁保证同一会话在窗口内只触发一次摘要任务。这是典型的「缓存 + 单飞」组合,防止并发重复计算。
Q:记忆为什么不用向量库? A:取舍。IM 场景的上下文是强时序的,最近 10/20 条窗口 + 更早的滚动摘要已经能覆盖绝大多数追问,实现成本极低且和前端聊天记录天然一致。引入向量库要额外承担 embedding 成本、切片策略、索引一致性和运维。我在文档里写明了升级路径:需要语义召回时平滑换成 pgvector 做「摘要 + 向量」双层检索。
集群与消息一致性
Q:多实例下消息怎么保证不丢? A:核心是新增的集群在线路由表 + 实例间消息总线。发消息时先落库(保证持久化),再查路由表确定目标用户挂在哪个实例:本机就直接推,别的实例就通过总线转发。路由表用 Redis 维护,带 TTL 续期。路由缺失时降级为广播到所有实例,宁可多推也不丢。原来没有这一层,跨实例消息是静默丢弃的。
Q:路由表为什么选 Redis,不用 etcd 或 ZooKeeper? A:项目已经依赖 Redis(会话缓存、验证码、消息 List),复用它做路由表零新增组件,部署复杂度不变。etcd/ZK 在一致性上更强,但引入后要多维护一个有状态集群,对这个体量的项目是过度设计。代价我也清楚:Redis 是单点,所以设计上保留了「路由失效降级广播」的兜底。
Q:Kafka 分区键为什么必须按会话维度? A:Kafka 只保证分区内有序。如果按消息 ID 或随机键分区,同一会话的消息可能落到不同分区被并行消费,消费者侧就会出现「后发的消息先被处理」,用户看到消息乱序。按会话维度分区,能保证同一会话的所有消息进同一分区、串行消费,顺序性由 Kafka 本身保证。
Q:acks 从默认改成 RequireOne 是什么考虑?为什么不选 All? A:原来的配置有缺陷,消息可靠性没有保障。RequireOne 表示 leader 写入成功即返回,在「不丢消息」和「吞吐/延迟」之间取平衡。All 需要等所有 ISR 副本确认,延迟更高;对聊天消息来说,绝大多数场景 RequireOne 足够,真正不能丢的关键操作我会走数据库事务而不是只依赖 MQ。
Q:Topic 自动创建有什么问题? A:自动创建依赖 broker 端 allow.auto.create.topics 配置,生产环境通常关闭;而且自动创建出来的分区数和副本数是默认值,不受控,等发现问题再改要重建 Topic。所以改成经 controller 显式创建,分区数、副本数、清理策略都在代码里可查。
稳定性与工程化
Q:为什么关停不能直接 close(channel)? A:因为往这个 channel 写的 goroutine 不止一个。Go 里 close 一个还有发送者的 channel 会直接 panic(send on closed channel),而且用 close 表达「结束」时接收方无法区分「正常关闭」和「已无数据」。我改成 done channel + context.Cancel:谁都不 close 数据通道,所有发送方 select 监听 done,收到就退出,再用 sync.WaitGroup 等所有 goroutine 收敛。这样既没有 panic 也没有泄漏。
Q:关停为什么不能清整个 Redis? A:原实现用 Scan + Del 清库,单机开发时看不出问题,多实例部署时一个实例退出会把其他实例的会话缓存、在线路由一起删掉,造成雪崩式缓存击穿。改成按前缀白名单删除,并且默认关闭清理动作。取舍是本地开发会残留脏数据,需要手动清,但生产正确性优先。
Q:dispatch.go 发现的那 2 个真实缺陷是什么?怎么发现的? A:这段分发的核心逻辑原来零测试,我先补了 18 个用例覆盖「私聊/群聊/触发词/落库/推送」各分支,跑起来就暴露了问题:一个是群聊 Agent 触发词判断写在了通用分支里,私聊和系统消息也会命中,导致重复回复并生成伪群 ID;另一个是关停路径的并发问题。这就是「测试是设计工具」的实例——不是我读代码看出来的,是测试逼出来的。
Q:QPS 多少?P99 多少?怎么测的? A:用 k6 测的,脚本在 bench/k6-ws.js 和 bench/k6-http.js。环境是 4 核 8G、channel 模式、单实例、不含 LLM。100 VU 时 6k 消息/分钟,P95 45ms / P99 90ms;200 VU 时 12k/min,P95 68ms / P99 130ms;500 VU 时 30k/min,P95 110ms / P99 220ms 并偶发 429;1000 VU 时 60k/min,P95 180ms / P99 350ms,限流生效。观察指标是 /metrics 里的 ws_connections、messages_total、channel_transmit_len。
Q:流量涨 10 倍会怎样?瓶颈在哪? A:先分链路。消息主链路:channel 模式会先撞上 Transmit 缓冲上限(1000 VU 时已经看到 429),解法是调大 CHANNEL_SIZE、放大 GORM 连接池,再往上必须切 Kafka 模式横向扩实例。切 Kafka 后瓶颈转移到消费者并发和 DB 写入,需要扩分区数 + 加 app 实例。Agent 链路:瓶颈在 LLM 供应商的并发配额,我这边靠独立协程 + 25s 超时 + 429 背压保护主链路不被拖死。
Q:这块你踩过什么坑? A:三个。一是 applyEnvOverrides 是空壳函数,文档说支持环境变量覆盖,实际什么都没做——我是写测试时才发现的,所以后来特别强调「文档宣称的能力必须有测试兜底」。二是 Kafka 的 Renew 报 NOSCRIPT,原因是 Lua 脚本没预加载,本地跑不出来的问题在集成测试里才暴露。三是 go.mod 里写了 go 1.26.1 这个不存在的版本,属于照抄模板没验证。这三个都指向同一个结论:没有 CI 的项目,文档和代码会逐渐脱节。
Q:如果重做你会改什么? A:四点。第一,先补 dispatch 测试再动集群,能少走弯路。第二,路由表不该压在 Redis 单点上,至少要加本地缓存 + 心跳续期,或引入 gossip。第三,Agent 记忆要往向量检索升级,摘要是有损的。第四,压测必须带上 Mock LLM,否则「Agent 链路能扛多少」这个问题答不上来。