07 白板手写题与反问收尾
面字节的校招/实习技术面,手写代码是固定环节。这一篇覆盖: A. 最可能被出的 6 道手写题(都跟你项目里的真实代码有关,所以你会「有话说」) B. 2 道系统设计题C. 反问清单D. 收尾话术
手写题都给了完整可编译的代码 + 为什么会考这题 + 答题时的加分动作。
A. 手写题
A1 ⭐⭐⭐ 实现一个「保序分批并发」执行器
为什么会考:这是你简历上的技术点(「只读工具同轮并发执行、副作用工具串行保序」)。面试官很可能会说「那你写一下这个」。
题目表述
给你一个任务列表,每个任务标记了是否「只读」,并有一个执行函数。要求:
- 连续的只读任务并发执行;
- 只读段遇到非只读任务时断开,非只读任务串行执行;
- 最终结果必须严格按原始顺序返回;
- 支持 context 取消。
参考实现
package main
import (
"context"
"fmt"
"sync"
"time"
)
type Task struct {
Name string
ReadOnly bool
Run func(ctx context.Context) (string, error)
}
// ExecuteBatched 保序分批并发执行。
func ExecuteBatched(ctx context.Context, tasks []Task) ([]string, error) {
results := make([]string, len(tasks)) // 预分配:索引即最终位置
i := 0
for i < len(tasks) {
if err := ctx.Err(); err != nil {
return results, err
}
if tasks[i].ReadOnly {
// 吃入连续只读区间 [i, j)
j := i
for j < len(tasks) && tasks[j].ReadOnly {
j++
}
var wg sync.WaitGroup
for k := i; k < j; k++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
// 每个 goroutine 写自己的索引,不同内存位置 → 无需锁
defer func() {
if r := recover(); r != nil {
results[idx] = fmt.Sprintf("panic: %v", r)
}
}()
out, err := tasks[idx].Run(ctx)
if err != nil {
results[idx] = fmt.Sprintf("error: %v", err)
return
}
results[idx] = out
}(k)
}
wg.Wait()
i = j
} else {
// 有副作用:串行
out, err := tasks[i].Run(ctx)
if err != nil {
results[i] = fmt.Sprintf("error: %v", err)
} else {
results[i] = out
}
i++
}
}
return results, nil
}验证代码(面试时可以先写这个,证明你的实现是对的)
func main() {
var mu sync.Mutex
var execOrder []string
record := func(s string) { mu.Lock(); execOrder = append(execOrder, s); mu.Unlock() }
tasks := []Task{
{Name: "read1", ReadOnly: true, Run: func(ctx context.Context) (string, error) {
time.Sleep(30 * time.Millisecond); record("read1"); return "r1", nil
}},
{Name: "read2", ReadOnly: true, Run: func(ctx context.Context) (string, error) {
time.Sleep(10 * time.Millisecond); record("read2"); return "r2", nil
}},
{Name: "read3", ReadOnly: true, Run: func(ctx context.Context) (string, error) {
time.Sleep(20 * time.Millisecond); record("read3"); return "r3", nil
}},
{Name: "write1", ReadOnly: false, Run: func(ctx context.Context) (string, error) {
record("write1"); return "w1", nil
}},
{Name: "read4", ReadOnly: true, Run: func(ctx context.Context) (string, error) {
time.Sleep(5 * time.Millisecond); record("read4"); return "r4", nil
}},
}
start := time.Now()
res, err := ExecuteBatched(context.Background(), tasks)
elapsed := time.Since(start)
fmt.Println("结果(应按原始顺序):", res)
fmt.Println("实际执行顺序(read 段应乱序,write1 后才是 read4):", execOrder)
fmt.Printf("总耗时: %v(三个 read 并发,理论 ~30ms 而不是 60ms)\n", elapsed)
_ = err
}预期输出:
- 结果顺序:
[r1 r2 r3 w1 r4](永远是原始顺序) - 执行顺序:read1/read2/read3 三个乱序(因为并发),但
write1一定在三个 read 之后、read4之前 - 总耗时:约 35ms(30ms 并发段 + write1 瞬间 + read4 的 5ms),而不是 65ms
加分动作(面试时主动说)
- 主动加
recover:「我加一层recover,因为并发执行的工具如果 panic,整个进程会挂掉。我的项目里这里其实没有 recover——这是个应该补的防御。」 - 解释为什么不需要锁:「每个 goroutine 写
results[idx],不同的索引对应不同的内存位置,所以没有数据竞争。如果是要append到同一个 slice 就必须加锁了。」 - 解释
(k)那个参数传递:「Go 1.22 之前,闭包捕获循环变量是共享的——所以必须显式传参。Go 1.22 改了这个语义,现在可以直接捕获。我写显式传参是为了兼容性和可读性。」 - 提到 context 取消的细节:「我在循环开头检查
ctx.Err()。更严谨的做法是:取消时给还没执行的任务填一个「已取消」的结果,保证返回的results长度和输入一致——否则调用方会拿到一堆空字符串,分不清是「没执行」还是「结果是空」。」
A2 ⭐⭐ 实现一个支持 ** 的 glob 匹配
为什么会考:你的权限规则引擎和 glob 工具都需要它,而且是经典的「DP / 递归」题。
题目表述
实现一个路径 glob 匹配:
*匹配段内任意字符,**匹配任意层级。例如:
src/*.go匹配src/a.go,不匹配src/a/b.gosrc/**匹配src/a/b/c.go**/*.go匹配a/b/c.go也匹配c.go
参考实现
package main
import (
"fmt"
"path/filepath"
"strings"
)
func Match(pattern, path string) bool {
pattern = filepath.ToSlash(pattern)
path = filepath.ToSlash(path)
return matchSegments(strings.Split(pattern, "/"), strings.Split(path, "/"))
}
func matchSegments(pat, path []string) bool {
if len(pat) == 0 {
return len(path) == 0
}
// 末尾是 ** → 匹配任意剩余
if len(pat) == 1 && pat[0] == "**" {
return true
}
// path 用完了 → pat 必须全是 ** 才匹配
if len(path) == 0 {
for _, p := range pat {
if p != "**" {
return false
}
}
return true
}
// ** 可以匹配 0 层(跳过 **)或 1+ 层(跳过 path 首段)
if pat[0] == "**" {
return matchSegments(pat[1:], path) || matchSegments(pat, path[1:])
}
ok, _ := filepath.Match(pat[0], path[0])
if !ok {
return false
}
return matchSegments(pat[1:], path[1:])
}
func main() {
cases := []struct{ pat, p string; want bool }{
{"src/*.go", "src/a.go", true},
{"src/*.go", "src/a/b.go", false}, // * 不跨 /
{"src/**", "src/a/b/c.go", true},
{"src/**", "src", true}, // ** 匹配 0 层
{"**/*.go", "a/b/c.go", true},
{"**/*.go", "c.go", true},
{"a/**/c.go", "a/c.go", true}, // ** 匹配 0 层
{"a/**/c.go", "a/b/x/c.go", true},
{"a/*/c.go", "a/b/x/c.go", false},
{"*.go", "a/b.go", false},
}
for _, c := range cases {
got := Match(c.pat, c.p)
mark := "✓"
if got != c.want { mark = "✗ FAIL" }
fmt.Printf("%s Match(%-12q, %-12q) = %v (want %v)\n", mark, c.pat, c.p, got, c.want)
}
}加分动作
- 主动说出复杂度问题:「这个纯递归实现的最坏复杂度是指数的——比如
a/**/**/**/b这种模式。要优化就加记忆化(memo[i][j]表示pat[i:]和path[j:]是否匹配),复杂度降到 O(m×n)。我的项目里没做记忆化,因为用户手写的规则模式很短,而且**很少连续出现。」 - 说明为什么不用
path.Match:「path.Match的*不跨/,而且它没有**语义——**在它看来就是两个连续的*,效果等于一个。所以只能自己实现分段匹配。但段内我用的是filepath.Match,那部分不用重写。」 - 提一个边界:「
a/**/c.go里**要能匹配 0 层(即a/c.go也匹配)。这个在实现里由matchSegments(pat[1:], path)那个分支保证——这是最容易写错的边界。」
A3 ⭐⭐⭐ 实现一个「决策一次、不可翻转」的幂等缓存
为什么会考:这是项目里 ContentReplacementState 的简化版。它考的是「并发 + 幂等 + 一次性决策」三个概念。
题目表述
有一个耗时的函数
decide(),会对每个 id 产出一个决定。要求:
- 同一个 id 只执行一次
decide();- 后续调用直接返回第一次的结果(不能重新决策);
- 如果
decide()返回「放弃」,不记录结果,允许下次重试;- 并发安全。
参考实现
package main
import (
"fmt"
"sync"
)
type Ledger struct {
mu sync.Mutex
decided map[string]string // id → 决定后的值
}
func NewLedger() *Ledger {
return &Ledger{decided: make(map[string]string)}
}
// DecideOnce 对 id 做一次性决策。
// decide 返回 (value, commit):
// - commit=true → 记录 value,返回 value
// - commit=false → 不记录,返回 fallback(下次可重试)
func (l *Ledger) DecideOnce(id, fallback string, decide func() (string, bool)) string {
l.mu.Lock()
defer l.mu.Unlock()
// 已决策 → 直接返回存量结果(不调用 decide)
if v, ok := l.decided[id]; ok {
return v
}
value, commit := decide() // ← 在持锁状态下调用
if commit {
l.decided[id] = value
return value
}
return fallback
}
func main() {
l := NewLedger()
calls := 0
decide := func() (string, bool) {
calls++
return "preview", true
}
fmt.Println(l.DecideOnce("t1", "orig", decide)) // preview
fmt.Println(l.DecideOnce("t1", "orig", decide)) // preview(不重新决策)
fmt.Println(l.DecideOnce("t1", "orig", decide)) // preview
fmt.Println("decide 被调用次数(应为 1):", calls)
// 失败可重试的场景
failCalls := 0
failDecide := func() (string, bool) {
failCalls++
if failCalls < 2 { return "", false } // 第一次失败
return "ok", true
}
fmt.Println(l.DecideOnce("t2", "orig", failDecide)) // orig(失败,未记录)
fmt.Println(l.DecideOnce("t2", "orig", failDecide)) // ok(重试成功)
fmt.Println("失败场景 decide 调用次数(应为 2):", failCalls)
}加分动作
- 主动解释「为什么回调在持锁时调用」:「这违反了「不要在持锁时执行外部代码」的一般原则。但在这里是必须的——因为要保证「查账本 → 决策 → 写账本」三步原子,否则两个 goroutine 可能同时判定「未决策」,都跑一遍
decide()。前提是decide()内部的代码不能回调Ledger自己(会死锁)——我的项目里decide只做「写文件 + 拼字符串」,所以安全。」 - 说出另一个方案:「另一个做法是用
sync.Once或者singleflight。但singleflight的语义是「合并并发请求」,不是「永久记住结果」——第一次调用结束后它就忘了。我这里需要的是「永久记住」,所以自己实现。」 - 关联到项目:「我在项目里用这个做上下文压缩的替换决策——同一个工具结果一旦决定落盘替换,就不允许在后续轮次里翻转。因为历史内容在轮次之间变化会破坏 Prompt Cache,也会让模型的视野悄悄改变。」
A4 ⭐⭐ 实现一个带熔断的重试
为什么会考:并发 + 状态机,很常见的题。
题目表述
实现一个重试器:
- 失败累计达到阈值后「跳闸」,直接返回错误不重试;
- 成功后清零;
- 手动调用时不计数、也不受跳闸影响(强制重试);
- 并发安全。
参考实现
package main
import (
"errors"
"fmt"
"sync"
"sync/atomic"
)
var ErrTripped = errors.New("circuit breaker tripped")
type Breaker struct {
mu sync.Mutex
failures int
threshold int
}
func NewBreaker(threshold int) *Breaker {
return &Breaker{threshold: threshold}
}
// Do 自动路径:受熔断约束。
func (b *Breaker) Do(fn func() error) error {
b.mu.Lock()
if b.failures >= b.threshold {
b.mu.Unlock()
return ErrTripped
}
b.mu.Unlock()
if err := fn(); err != nil {
b.mu.Lock()
b.failures++
b.mu.Unlock()
return err
}
b.mu.Lock()
b.failures = 0
b.mu.Unlock()
return nil
}
// Force 手动路径:不受熔断约束、不计入失败。
func (b *Breaker) Force(fn func() error) error {
return fn()
}
func main() {
b := NewBreaker(3)
var attempts int32
fail := func() error { atomic.AddInt32(&attempts, 1); return errors.New("boom") }
for i := 0; i < 5; i++ {
err := b.Do(fail)
fmt.Printf("第 %d 次: %v\n", i+1, err)
}
fmt.Println("实际尝试次数(应为 3,第 4、5 次被熔断拦住):", atomic.LoadInt32(&attempts))
// 手动路径不受熔断影响
fmt.Println("Force 在熔断状态下:", b.Force(fail))
fmt.Println("Force 后总尝试次数(应为 4):", atomic.LoadInt32(&attempts))
}加分动作
- 说出「半开状态」:「我实现的是两态熔断(closed / open)。标准的三态模型还应该有 half-open——跳闸后隔一段时间允许一次试探,成功就恢复到 closed。我的项目里没做这个——一旦连续 3 次失败,整个会话内自动压缩就永久失效了,即使失败原因(比如限流)已经恢复。这是个应该补的地方。」
- 说明为什么手动路径不计数:「因为熔断防的是「无谓的重复尝试」,不是「必要的兜底」。手动
/compact是用户的明确意图,他应该能再试一次;而紧急压缩是最后一道防线,如果被熔断挡住用户就彻底没救了。」 - 说明为什么阈值是 3:「因为失败可能是瞬时的(网络抖动、限流)。立即熔断意味着一次抖动就永久失去能力。3 次大概覆盖 30 秒的时间窗——如果还在失败,说明不是抖动而是真故障。」
A5 ⭐⭐ 生产者-消费者:如何保证不泄漏 goroutine
为什么会考:Go 面试的必考题。而且你项目里到处都是这个模式。
题目表述
实现一个「生产者 goroutine 向 channel 发事件、消费者读取」的模式。要求:
- 消费者中途退出时,生产者不能永久阻塞;
- 生产者结束时消费者要能感知;
- 不要泄漏 goroutine。
参考实现
package main
import (
"context"
"fmt"
"time"
)
type Event struct{ Text string }
// Produce 生产者:返回只读 channel。
func Produce(ctx context.Context, n int) <-chan Event {
ch := make(chan Event) // 无缓冲 → 强背压
go func() {
defer close(ch) // ← 必须:让消费者能感知结束
for i := 0; i < n; i++ {
select {
case ch <- Event{Text: fmt.Sprintf("evt-%d", i)}:
// 发送成功
case <-ctx.Done():
return // ← 必须:消费者消失时靠这个解锁
}
time.Sleep(50 * time.Millisecond)
}
}()
return ch
}
func main() {
// 场景 1:正常消费完
ctx1, cancel1 := context.WithCancel(context.Background())
defer cancel1()
count := 0
for ev := range Produce(ctx1, 5) {
count++
_ = ev
}
fmt.Println("场景1 消费事件数:", count)
// 场景 2:消费者提前退出(只读 2 条就取消)
ctx2, cancel2 := context.WithCancel(context.Background())
count2 := 0
for range Produce(ctx2, 100) {
count2++
if count2 == 2 {
cancel2() // ← 取消后生产者会从 select 的 ctx.Done() 返回
break // ← 消费者退出循环
}
}
fmt.Println("场景2 消费事件数:", count2)
time.Sleep(200 * time.Millisecond)
fmt.Println("没有 goroutine 泄漏(程序能正常退出)")
}加分动作
- 说出「两端都要处理」:「生产端要
defer close(ch),不然消费者range永远不退出;消费端退出时必须cancel(),不然生产者卡在ch <-上。两个方向都要防。」 - 关联到项目:「我的项目里这个模式叫
emit,有大概 30 个调用点:go每个调用点都要处理func emit(ctx context.Context, ch chan<- Event, e Event) bool { select { case ch <- e: return true case <-ctx.Done(): return false } }false分支(提前 return 并回填剩余结果)。这是 channel 方案的隐性成本——代码比 callback 啰嗦,但取消语义更干净。」 - 讲背压:「我用无缓冲 channel 是刻意的。无缓冲 = 强背压——消费者不读,生产者就阻塞。在 Agent 场景下这是对的:UI 卡住时不该让 LLM 请求继续往里灌数据。 但代价是 TUI 每渲染一帧就卡住 agent 一次——这是一个真实的开销,我用 benchmark 量过。」
- 如果被问「怎么验证没有泄漏」:「用
runtime.NumGoroutine()前后对比,或者用go.uber.org/goleak这个库。最直接的验证是「程序能正常退出」——如果 goroutine 泄漏了,main返回后进程不会退出(或者测试会挂)。」
A6 ⭐⭐ 给一个「未知工具」加进系统,你要改哪些文件?
为什么会考:这题考「你对项目的架构掌握程度」,而且比纯算法题更能看出你是不是真懂。
题目表述
假设现在要给这个 Agent 加一个新工具,叫
http_request(发 HTTP 请求)。你要动哪些文件?大概多少行?
参考实现(口头作答即可)
「核心改动只有 1 个新文件,加上 1 处注册。
① 新建
internal/tool/http_request.go,实现tool.Tool五个方法:gotype httpRequestTool struct{} func (t *httpRequestTool) Name() string { return "http_request" } func (t *httpRequestTool) Description() string { return "发起 HTTP 请求并返回响应。" } func (t *httpRequestTool) Parameters() map[string]any { /* JSON Schema: url/method/headers/body */ } func (t *httpRequestTool) ReadOnly() bool { return false } // ← 有副作用 func (t *httpRequestTool) Execute(ctx context.Context, args json.RawMessage) tool.Result { ... }② 在
tool.NewDefaultRegistry()里加一行注册:gofunc NewDefaultRegistry() *Registry { r := &Registry{} r.Register(&readFileTool{}) // ... 其他 5 个 ... r.Register(&httpRequestTool{}) // ← 新增 return r }就这样,其他东西自动生效:
- 自动进
Registry.Definitions(),模型能看到;- 自动走权限判定(
ReadOnly() == false→ 归CategoryExec→ Default 模式下判Ask);- 自动参与并发分批(非只读 → 串行);
- 自动被
executeBatched加 30 秒超时。但有一个地方需要额外处理:
permission.extractTarget里没有http_request的分支,所以权限判定拿不到 target。
- 沙箱:不生效(因为
isFile是 false)——对这个工具是对的(它不操作文件);- 黑名单:
target == ""所以不检查——这有点问题,如果我想拦「请求内网地址」,现在做不到。- 用户写的规则
HttpRequest(https://evil.com/*)也匹配不上(因为 target 恒为空串)——这就是我在 MCP 工具上发现的那个 bug 的同类问题。所以正确的做法是:在
extractTarget里加一个http_request分支,返回 URL 作为 target;同时在friendlyName里加上http_request → HttpRequest的映射。这样规则引擎和黑名单就都能覆盖它了。总共大概是:新文件 120 行左右 +
registry.go1 行 +settings.go约 8 行。」如果要测得充分,还要加
tool_test.go的测试用例。
加分动作
- 主动指出「加工具会遇到什么坑」:上面那个
extractTarget的问题。这是「你知道自己项目的扩展点在哪」的证明。 - 说出「如果是有副作用的工具,还要考虑什么」:「①
ReadOnly()必须返回 false,否则会被并发执行;② 要考虑是否有幂等性——如果有,可以在权限规则里用allow放行;③ 如果是网络操作,超时应该比 30 秒短(我的默认 30 秒对 HTTP 请求偏长);④ 要考虑 SSRF——用户可能让 Agent 请求http://169.254.169.254/(云元数据服务),这需要在黑名单或者专用校验里拦。」
B. 系统设计题
B1 ⭐⭐ 如果要把这个工具做成「团队共享的云端 Agent 服务」,你要改什么?
为什么会考:这是「你的项目能不能长大」的题。字节做的是规模化产品,这个问题非常实际。
参考回答框架
「我会按「隔离 / 多租户 / 可观测 / 成本」四个维度说,每一条都讲清楚为什么现在的实现不够。
① 隔离——这是最大的改动,也是我现在的最大缺陷。 现在的沙箱是参数级的,只拦文件类工具的路径参数,对
bash无效。在单用户本地场景下靠「默认弹窗」兜底,但在云端这是灾难——因为用户是恶意的或者至少是不可控的。 必须改成每个任务一个独立沙箱容器:① 用 K8s Pod 或者 gVisor/Firecracker 做进程级 + 内核级隔离;② 文件系统用只读的基线镜像 + 每任务一个可写 overlay;③ 网络默认全禁,只允许白名单出网(因为 Agent 会执行curl);④ 资源限额(CPU / 内存 / 磁盘 / 进程数)。② 多租户——现在的状态是「全局单例」。 现在
Registry、permission.Engine、SessionRuntime都是进程内单例,会话目录是<项目>/.mewcode/sessions/。云端要:① 每次请求带租户 ID + 项目 ID,状态按租户隔离;② 权限引擎要支持服务端下发的策略(现在只有三层本地 YAML);③ 密钥不能放在用户的配置文件里——要用服务端托管的密钥服务,按租户下发、用完即焚。 这里有个我现在就意识到的问题:我的bash工具刻意清空了环境变量只留 4 个(防密钥泄漏给模型生成的命令)——这个设计在云端是正确的。但 MCP 的 stdio server 是继承全部环境变量的——如果云端用共享进程跑 MCP server,密钥会泄漏到别的租户。所以云端必须每个租户一个 MCP 进程。③ 可观测——现在几乎是零。 我只有 stderr 日志和 TUI 上显示的 token 数。云端需要:① 分布式 trace——一次请求的完整链路(HTTP 入口 → Agent 循环 → 每轮 LLM 调用 → 每次工具执行 → 权限判定),用 OpenTelemetry 串起来;② 指标——每轮的 token 用量、工具调用次数、权限判定分布(Allow/Deny/Ask 各占多少)、压缩触发次数、停止原因分布;③ 审计日志——所有被放行/拒绝的操作留不可篡改的记录,这是企业客户的核心需求。 顺带说,我在项目里已经埋了一些点——
CompactEvent带 before/after token、Event{Usage}带四个维度的 token 数。但这些只打到 TUI 上,没有上报。 要改成「事件流多路消费」——一路给 UI、一路给上报。④ 成本控制——现在完全没有。 用户只能看着状态栏的累计 token 自己判断(然后手动按 Esc)。云端必须:① 每次任务有 token 预算上限,超了就停;② 按租户配额(每天多少 token、多少并发);③ 模型路由——简单任务用便宜模型、复杂任务用贵的。我的 Provider 抽象天然支持这个(改一个配置就换模型),但没有「按任务难度选模型」的策略层。 还有一个成本优化我做了但可以做得更好:Prompt Cache。我的系统提示稳定段打了缓存断点、历史是只追加的,所以前缀能命中。但压缩会重写历史,把所有缓存打掉。 云端可能需要更细粒度的缓存策略(比如分层摘要,只压最老的部分)。」
加分动作
这题的得分点在于「每条都说清了现在的实现为什么不够」——不是空谈云端架构,而是从自己的代码出发。特别是那条「MCP 继承全部环境变量在云端会泄漏密钥」——这是只有真读过代码才能想到的。
B2 ⭐ 如何给这个 Agent 加「成本预算」功能?
为什么会考:小而具体的设计题,能看出你的工程细化能力。
参考回答
「分三层:采集 → 判定 → 干预。
① 采集:token 用量现在已经有完整的数据链——
provider 返回 Usage → StreamEvent{Usage} → agent emit(Event{Usage}) → TUI 累加 m.usageIn / m.usageOut我需要在会话级再维护一个累计值 + 一个预算配置。
② 判定:在
Agent.Run的每轮开头检查(跟压缩的判定在同一个位置):剩余预算 = 预算上限 − 已消耗 如果剩余预算 <= 0 → 停止循环,发 Notice 如果剩余预算 <= 单轮预估上限 → 再跑最后一轮,然后强制停关键是「单轮预估上限」怎么定——最坏情况是一轮发出整个上下文(比如 167K)+ 4096 输出。所以要在最后一轮预留这个量。
③ 干预:三种力度——
- 软提示:剩余预算低于 20% 时,在 reminder 里注入一条「请尽快收敛,剩余预算有限」。这利用了 Agent 的自我调节能力,比硬截断优雅。
- 硬停止:到 0 就停,发 Notice「本次任务已达 token 预算上限(X),可继续发消息增加预算」。注意提示文案要跟「25 轮上限」那个是同一个风格——告诉用户「可以继续」,而不是「失败了」。
- 降级:接近预算时自动切到便宜模型(如果有配置)。这是进阶能力,我的 Provider 抽象支持,但需要一个「模型路由」层。
一个实现上的细节:预算是会话级还是任务级?
- 会话级:跨多轮对话累计,用户发下一句话不重置。适合「每天给我这么多额度」。
- 任务级:每次
Run重置。适合「这个任务最多花这么多」。我倾向两个都有:会话级做总量控制,任务级做单次控制。而且优先触发的是任务级——因为一个失控的单次任务可能一瞬间烧掉全天预算。」
加分动作
主动指出「这个功能会影响 Prompt Cache」:
「有个细节要注意:如果我在 reminder 里注入「剩余预算有限」这种提示,它每轮都不一样(因为剩余量在变)——但这没关系,因为 reminder 是通过
appendReminder挂在消息尾部的(不在缓存断点之前),所以不影响稳定段的缓存命中。」
C. 反问清单
反问环节不是走过场。 面试官会从你问的问题判断你的水平层次。
不好:薪资、加班、什么时候出结果(这些留给 HR 面) 不好:「你们用什么技术栈」——太泛,官网就能查到 好:问「技术决策」和「团队的真实问题」
按优先级排的 8 个反问
🌟 一级(必问 1–2 个,直接体现你的层次)
1. 「你们现在的 Agent 产品,权限/安全这块最难的地方是什么?是模型不听话,还是沙箱做不到位,还是用户嫌弹窗烦?」
为什么好:① 直接问到了你的项目最强的地方(说明你真在思考这个领域);② 三个选项给了面试官具体的抓手,他很容易接话;③ 不管他回答哪个,你都能接上自己的项目经验。
接话示例:如果他说「用户嫌弹窗烦」——
「这个我特别有共鸣。我的 MCP 工具默认会弹窗(因为未知工具归最严档),用户体验就是每次都要点。我后来发现根因是我的规则匹配对 MCP 工具失效——所以用户想写通配规则放行也写不了,只能一个个精确授权。这块的平衡确实很难:安全的默认值往往是体验最差的默认值。」
2. 「这个岗位的同学,入职后前三个月最需要补的能力是什么?是模型侧的知识,还是后端工程,还是产品判断?」
为什么好:① 展示你关心「怎么快速上手」,不是只关心自己;② 面试官的回答能让你判断这个岗位适不适合你;③ 他的回答本身也是信息——如果他说「补产品判断」,说明这个团队在往产品方向走。
⭐ 二级(选 1–2 个,展示技术兴趣)
3. 「你们评估一个 Agent 任务的成败,是靠人工看,还是有一套自动化评测?如果自动化,评测集是怎么维护的?」
为什么好:这是 Agent 工程里最实际、也最难的问题(评估)。问这个说明你懂「做 Agent 最难的不是做出来,是知道它变好还是变差」。
接话示例:
「我自己的项目里这一点做得很差——我只有单元测试和一些 tmux 端到端的手工验收,没有自动化的任务级评测。比如「重构 auth 模块」这种任务,我怎么知道改动之后的 Agent 比之前好?我判断不了。所以我很想知道工业界是怎么做的。」
4. 「你们用 MCP 吗?如果用了,生产环境怎么处理第三方 MCP server 的信任问题——比如它返回的内容里带 prompt injection?」
为什么好:这是个真实且未被很好解决的问题,问出来显得你在思考前沿。而且你项目里正好有 MCP 客户端,你有资格问这个。
接话示例:
「我的项目里对这个问题完全没有防护——工具结果是直接拼进对话历史的。我知道有几种思路(比如给工具结果加「这是外部数据不是指令」的标注、或者用另一个模型做内容审核),但我不确定在生产环境里哪个方案真的有效。」
5. 「团队现在是在做 0→1 还是 1→100?如果是 1→100,当前最大的技术债是什么?」
为什么好:① 判断团队阶段;② 「技术债」这个词说明你懂工程是取舍的艺术——这正好呼应你项目里那些「我知道有缺陷但当时没修」的经历。
接话示例:
「我自己的项目就有明确的债——比如沙箱停在参数层、没有下沉到进程层;再比如 session 和 compact 这两个核心模块没有单元测试。我知道它们该修,当时是在「赶功能」和「做扎实」之间选了前者。」
⚪ 三级(可选,适合气氛轻松时)
6. 「你们的 Agent 有没有成本上面临过「一次任务烧掉几百块」的情况?如果有,是怎么控制的?」
7. 「团队怎么看『模型能力提升会取代一部分 Agent 工程』这件事?比如模型自己越来越会规划,那我们做的编排层会不会变薄?」
为什么好:这是个有争议的开放问题,能引发真正的讨论。你的观点:
「我的看法是编排层不会变薄,但会变形。模型越强,「怎么调工具」这件事越不需要我操心;但**「权限在哪拦、上下文怎么省、成本怎么控、失败了怎么办」这些不会因为模型变强而消失**——因为它们不是「智能问题」,是「工程约束问题」。模型越强,它能在单位时间里做越多的事,这些约束就越重要。」
8. 「如果我入职,有机会接触到现在项目里最棘手的那个问题吗?」
反问的三个禁忌
| 禁忌 | 为什么 |
|---|---|
| 不要问官网能查到的 | 「你们用什么语言/框架」——说明你没做功课 |
| 不要在技术面问薪资 | 留给 HR 面 |
| 不要问「我能过吗」 | 让面试官为难,而且显得没底气 |
如果面试官说「你有什么想问我的吗」,但你已经问完了
用这句收尾:
「技术上我主要想了解的都问到了。最后我想说一句感受:准备这个项目的过程中,我最大的收获不是学会了写 Agent,而是意识到「知道自己的边界在哪」比「把功能做出来」难得多。
比如我的沙箱是参数级的而不是进程级的、权限配置解析失败是 fail-open 而不是 fail-closed、核心模块的测试覆盖是错位的——这些都不是「我不知道怎么做」,而是「我当时判断错了投入的方向」。如果能有 mentors 帮我更快地建立这种判断力,是我最期待的。」
D. 面试收尾的三句话
1. 如果面试官问「你还有什么要补充的吗」
「我想补充一点:这个项目的价值对我来说不只是简历上的一个条目。
它让我第一次完整地走了一遍「从零做一个有真实约束的系统」的过程——约束来自协议(Anthropic 和 OpenAI 的差异)、来自成本(token 预算)、来自安全(权限分层)、来自并发(工具执行的顺序语义)、来自数据(崩溃后的状态恢复)。
这些约束之间是互相冲突的——比如压缩能省 token,但会破坏 Prompt Cache;比如并发能提性能,但会带来顺序和权限的问题;比如沙箱能提安全,但会让正常的文件操作变复杂。我这一路上做的最多的事,就是在这些冲突里找平衡点,而且经常找错、然后再改。
我觉得这比「学会写 Go」或者「学会调 LLM API」重要得多。」
2. 如果面试官问「你觉得你这次表现怎么样」
不要过度自谦,也不要自夸:
「我觉得讲清楚了的部分是权限和压缩这两块的设计权衡——因为这两块我是真的反复改过,知道每个决定的代价在哪。
讲得不够好的地方是:如果问到我项目里没实现的部分(比如多租户、可观测性),我只能讲思路,讲不了经验。还有 MCP 那块我只做了客户端,没写过 server,所以如果问 server 侧的实现细节我会露怯。」
为什么这样答好:它展示了你能准确评估自己——这是高级工程师的核心能力。
3. 最后的收尾(起身前)
「谢谢面试官,今天聊得很过瘾——特别是你问的那些细节,让我重新想了一遍自己当初为什么那么设计。」
这句话的作用:它把面试变成了一次技术交流,而不是单向考核。面试官会记住你的。
附:面试前一天的最后检查
必须能不出声默背的 5 段
01文档的三分钟口述稿 —— 带停顿标记的那版04文档 Q4.1(五层权限的顺序和理由)04文档 Q4.6(沙箱对 bash 无效的完整论述)06文档场景 1(AI 辅助写的标准答法)06文档发现 1(记忆更新不可达——核弹级发现)
必须能手写的 2 段代码
07文档 A1(保序分批并发)07文档 A3(决策一次、不可翻转的账本)
必须能脱口而出的 5 个数字
- 101 个文件 / 14,082 行(加测试 17,984 行)
- 25 轮迭代上限(不是 10!)
- 5 层权限 / 4 档模式 / 10 条黑名单正则
mcp__<server>__<tool>(双下划线)- 窗口 − 20000 − 13000 是压缩阈值
最后一句
你已经把项目读透了。剩下唯一要做的就是——出声说。
默读会骗你,你以为你会了,一张嘴就卡壳。把 01 和三分钟稿念三遍,比再看一遍 04 更有用。
祝拿到 offer。🎯