Skip to content

07 白板手写题与反问收尾 ​

面字节的校招/实习技术面,手写代码是固定环节。这一篇覆盖: A. 最可能被出的 6 道手写题(都跟你项目里的真实代码有关,所以你会「有话说」) B. 2 道系统设计题C. 反问清单D. 收尾话术

手写题都给了完整可编译的代码 + 为什么会考这题 + 答题时的加分动作。


A. 手写题 ​


A1 ⭐⭐⭐ 实现一个「保序分批并发」执行器 ​

为什么会考:这是你简历上的技术点(「只读工具同轮并发执行、副作用工具串行保序」)。面试官很可能会说「那你写一下这个」。

题目表述 ​

给你一个任务列表,每个任务标记了是否「只读」,并有一个执行函数。要求:

  1. 连续的只读任务并发执行;
  2. 只读段遇到非只读任务时断开,非只读任务串行执行;
  3. 最终结果必须严格按原始顺序返回;
  4. 支持 context 取消。

参考实现 ​

go
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
}

验证代码(面试时可以先写这个,证明你的实现是对的) ​

go
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

加分动作(面试时主动说) ​

  1. 主动加 recover:「我加一层 recover,因为并发执行的工具如果 panic,整个进程会挂掉。我的项目里这里其实没有 recover——这是个应该补的防御。」
  2. 解释为什么不需要锁:「每个 goroutine 写 results[idx],不同的索引对应不同的内存位置,所以没有数据竞争。如果是要 append 到同一个 slice 就必须加锁了。」
  3. 解释 (k) 那个参数传递:「Go 1.22 之前,闭包捕获循环变量是共享的——所以必须显式传参。Go 1.22 改了这个语义,现在可以直接捕获。我写显式传参是为了兼容性和可读性。」
  4. 提到 context 取消的细节:「我在循环开头检查 ctx.Err()。更严谨的做法是:取消时给还没执行的任务填一个「已取消」的结果,保证返回的 results 长度和输入一致——否则调用方会拿到一堆空字符串,分不清是「没执行」还是「结果是空」。」

A2 ⭐⭐ 实现一个支持 ** 的 glob 匹配 ​

为什么会考:你的权限规则引擎和 glob 工具都需要它,而且是经典的「DP / 递归」题。

题目表述 ​

实现一个路径 glob 匹配:* 匹配段内任意字符,** 匹配任意层级。例如:

  • src/*.go 匹配 src/a.go,不匹配 src/a/b.go
  • src/** 匹配 src/a/b/c.go
  • **/*.go 匹配 a/b/c.go 也匹配 c.go

参考实现 ​

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)
	}
}

加分动作 ​

  1. 主动说出复杂度问题:「这个纯递归实现的最坏复杂度是指数的——比如 a/**/**/**/b 这种模式。要优化就加记忆化(memo[i][j] 表示 pat[i:] 和 path[j:] 是否匹配),复杂度降到 O(m×n)。我的项目里没做记忆化,因为用户手写的规则模式很短,而且 ** 很少连续出现。」
  2. 说明为什么不用 path.Match:「path.Match 的 * 不跨 /,而且它没有 ** 语义——** 在它看来就是两个连续的 *,效果等于一个。所以只能自己实现分段匹配。但段内我用的是 filepath.Match,那部分不用重写。」
  3. 提一个边界:「a/**/c.go 里 ** 要能匹配 0 层(即 a/c.go 也匹配)。这个在实现里由 matchSegments(pat[1:], path) 那个分支保证——这是最容易写错的边界。」

A3 ⭐⭐⭐ 实现一个「决策一次、不可翻转」的幂等缓存 ​

为什么会考:这是项目里 ContentReplacementState 的简化版。它考的是「并发 + 幂等 + 一次性决策」三个概念。

题目表述 ​

有一个耗时的函数 decide(),会对每个 id 产出一个决定。要求:

  1. 同一个 id 只执行一次 decide();
  2. 后续调用直接返回第一次的结果(不能重新决策);
  3. 如果 decide() 返回「放弃」,不记录结果,允许下次重试;
  4. 并发安全。

参考实现 ​

go
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)
}

加分动作 ​

  1. 主动解释「为什么回调在持锁时调用」:「这违反了「不要在持锁时执行外部代码」的一般原则。但在这里是必须的——因为要保证「查账本 → 决策 → 写账本」三步原子,否则两个 goroutine 可能同时判定「未决策」,都跑一遍 decide()。前提是 decide() 内部的代码不能回调 Ledger 自己(会死锁)——我的项目里 decide 只做「写文件 + 拼字符串」,所以安全。」
  2. 说出另一个方案:「另一个做法是用 sync.Once 或者 singleflight。但 singleflight 的语义是「合并并发请求」,不是「永久记住结果」——第一次调用结束后它就忘了。我这里需要的是「永久记住」,所以自己实现。」
  3. 关联到项目:「我在项目里用这个做上下文压缩的替换决策——同一个工具结果一旦决定落盘替换,就不允许在后续轮次里翻转。因为历史内容在轮次之间变化会破坏 Prompt Cache,也会让模型的视野悄悄改变。」

A4 ⭐⭐ 实现一个带熔断的重试 ​

为什么会考:并发 + 状态机,很常见的题。

题目表述 ​

实现一个重试器:

  1. 失败累计达到阈值后「跳闸」,直接返回错误不重试;
  2. 成功后清零;
  3. 手动调用时不计数、也不受跳闸影响(强制重试);
  4. 并发安全。

参考实现 ​

go
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))
}

加分动作 ​

  1. 说出「半开状态」:「我实现的是两态熔断(closed / open)。标准的三态模型还应该有 half-open——跳闸后隔一段时间允许一次试探,成功就恢复到 closed。我的项目里没做这个——一旦连续 3 次失败,整个会话内自动压缩就永久失效了,即使失败原因(比如限流)已经恢复。这是个应该补的地方。」
  2. 说明为什么手动路径不计数:「因为熔断防的是「无谓的重复尝试」,不是「必要的兜底」。手动 /compact 是用户的明确意图,他应该能再试一次;而紧急压缩是最后一道防线,如果被熔断挡住用户就彻底没救了。」
  3. 说明为什么阈值是 3:「因为失败可能是瞬时的(网络抖动、限流)。立即熔断意味着一次抖动就永久失去能力。3 次大概覆盖 30 秒的时间窗——如果还在失败,说明不是抖动而是真故障。」

A5 ⭐⭐ 生产者-消费者:如何保证不泄漏 goroutine ​

为什么会考:Go 面试的必考题。而且你项目里到处都是这个模式。

题目表述 ​

实现一个「生产者 goroutine 向 channel 发事件、消费者读取」的模式。要求:

  1. 消费者中途退出时,生产者不能永久阻塞;
  2. 生产者结束时消费者要能感知;
  3. 不要泄漏 goroutine。

参考实现 ​

go
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 泄漏(程序能正常退出)")
}

加分动作 ​

  1. 说出「两端都要处理」:「生产端要 defer close(ch),不然消费者 range 永远不退出;消费端退出时必须 cancel(),不然生产者卡在 ch <- 上。两个方向都要防。」
  2. 关联到项目:「我的项目里这个模式叫 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 啰嗦,但取消语义更干净。」
  3. 讲背压:「我用无缓冲 channel 是刻意的。无缓冲 = 强背压——消费者不读,生产者就阻塞。在 Agent 场景下这是对的:UI 卡住时不该让 LLM 请求继续往里灌数据。 但代价是 TUI 每渲染一帧就卡住 agent 一次——这是一个真实的开销,我用 benchmark 量过。」
  4. 如果被问「怎么验证没有泄漏」:「用 runtime.NumGoroutine() 前后对比,或者用 go.uber.org/goleak 这个库。最直接的验证是「程序能正常退出」——如果 goroutine 泄漏了,main 返回后进程不会退出(或者测试会挂)。」

A6 ⭐⭐ 给一个「未知工具」加进系统,你要改哪些文件? ​

为什么会考:这题考「你对项目的架构掌握程度」,而且比纯算法题更能看出你是不是真懂。

题目表述 ​

假设现在要给这个 Agent 加一个新工具,叫 http_request(发 HTTP 请求)。你要动哪些文件?大概多少行?

参考实现(口头作答即可) ​

「核心改动只有 1 个新文件,加上 1 处注册。

① 新建 internal/tool/http_request.go,实现 tool.Tool 五个方法:

go
type 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() 里加一行注册:

go
func 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.go 1 行 + settings.go 约 8 行。」

如果要测得充分,还要加 tool_test.go 的测试用例。

加分动作 ​

  1. 主动指出「加工具会遇到什么坑」:上面那个 extractTarget 的问题。这是「你知道自己项目的扩展点在哪」的证明。
  2. 说出「如果是有副作用的工具,还要考虑什么」:「① 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 段 ​

  1. 01 文档的三分钟口述稿 —— 带停顿标记的那版
  2. 04 文档 Q4.1(五层权限的顺序和理由)
  3. 04 文档 Q4.6(沙箱对 bash 无效的完整论述)
  4. 06 文档场景 1(AI 辅助写的标准答法)
  5. 06 文档发现 1(记忆更新不可达——核弹级发现)

必须能手写的 2 段代码 ​

  1. 07 文档 A1(保序分批并发)
  2. 07 文档 A3(决策一次、不可翻转的账本)

必须能脱口而出的 5 个数字 ​

  • 101 个文件 / 14,082 行(加测试 17,984 行)
  • 25 轮迭代上限(不是 10!)
  • 5 层权限 / 4 档模式 / 10 条黑名单正则
  • mcp__<server>__<tool>(双下划线)
  • 窗口 − 20000 − 13000 是压缩阈值

最后一句 ​

你已经把项目读透了。剩下唯一要做的就是——出声说。

默读会骗你,你以为你会了,一张嘴就卡壳。把 01 和三分钟稿念三遍,比再看一遍 04 更有用。

祝拿到 offer。🎯

持续学习,持续构建。