好文集ChatGPT Plus¥119

【小路独家】Anthropic 面试真题:profiler 采样还原调用事件(3 Parts / 55 分钟)

文章目录

(Anthropic Real Interview Question: Reconstructing Call Events from Profiler Samples (3 Parts / 55 min)

题目要求:采样 profiler 只能看到快照

profiler 每隔一段时间抓一次调用栈:Sample(ts, stack),stack 从外层(main)到内层(正在执行的函数)。

你看不到函数真正的进入 / 返回时刻——两次采样之间发生又结束的调用,完全 invisible,只能靠快照序列反推。

两个约定(动手前先跟面试官确认):

  1. frame 的身份是 (depth, name),不是 name。递归时同一名字在不同深度 = 两个不同的 frame。

  2. 事件的时间戳是"观察到变化的时刻",不是函数真正的起止时刻。

Part 1:快照 → 事件

convert_samples_to_events(samples, end_time),samples 按 ts 严格递增。

  1. 第一个 sample:栈里每个 frame 全部 start,从外到内,时间戳记第一个 sample 的 ts。

  2. 相邻两个 sample:从外向内逐位对比,找最长公共前缀 LCP。老栈里位置 ≥ p 的 frame 全都 end(从内到外——被调者先返回);新栈里位置 ≥ p 的 frame 全都 start(从外到内——调用者先入栈)。时间戳都记后一个 sample 的 ts。

  3. 相邻 stack 完全一样 → 零事件(这就是压缩)。

  4. end_time:最后一个 sample 里还活着的 frame 全部 end,从内到外。注意:input 耗尽不会提前关 frame,只有 end_time 能关。

例子:

t=0.0: [main]
t=1.0: [main, solve]
t=2.0: [main, solve, solve]       # 递归:两个不同的 solve frame
t=2.5: [main, solve, solve]       # 完全一样 → 无事件
t=4.0: [main, solve, solve, solve]
t=6.0: [main, solve, helper]      # 内层两个 solve 返回,helper 进来
end_time = 9.0

事件流:start main@0,start solve@1(t=1.0),start solve@2(t=2.0),start solve@3(t=4.0);t=6.0 时 LCP=2:先 end solve@3、end solve@2(从内到外),再 start helper@2;t=9.0:end helper@2、end solve@1、end main@0(从内到外)。

文章配图

图 1 · Part 1 总览:绿色框 = 本轮新 start 的 frame;t=6.0 处两个 solve end(红)、helper start(绿);下方是还原出的事件时间线

文章配图

图 2 · LCP diff 细节:从外向内逐位对比找公共前缀,公共前缀不动;变化部分先 end(内→外)再 start(外→内)

Part 1 参考代码

from typing import Literal, NamedTuple

class Sample(NamedTuple):
    ts: float
    stack: list[str]          # 外层在前,内层在后

class Event(NamedTuple):
    kind: Literal["start", "end"]
    ts: float
    name: str
    depth: int                # frame 身份 = (depth, name)

def _lcp_len(a, b):
    n = 0
    while n < len(a) and n < len(b) and a[n] == b[n]:
        n += 1
    return n

def convert_samples_to_events(samples, end_time):
    if not samples:
        return []
    events = []
    first = samples[0]
    for depth, name in enumerate(first.stack):   # 第一个 sample:全员 start,从外到内
        events.append(Event("start", first.ts, name, depth))
    prev = first.stack
    for s in samples[1:]:
        p = _lcp_len(prev, s.stack)
        for d in range(len(prev) - 1, p - 1, -1): # 先 end:从内到外
            events.append(Event("end", s.ts, prev[d], d))
        for d in range(p, len(s.stack)):          # 再 start:从外到内
            events.append(Event("start", s.ts, s.stack[d], d))
        prev = s.stack
    for d in range(len(prev) - 1, -1, -1):        # end_time:全关,从内到外
        events.append(Event("end", end_time, prev[d], d))
    return events

Part 2:流式 + 去噪

真实 profiler 是流式吐 sample 的:stream_debounced_events(samples, k)。

  • samples 是只能读一次的 iterator

  • 只保留 O(depth) 的状态,事件一旦确定就 yield

  • 没有 end_time:输入结束时还没确认关闭的 frame,直接没有 end 事件

去噪(debounce):采样有抖动,某一帧可能昙花一现。规则:一个 frame 必须在连续 k 个 sample 里占据同一位置,才发 start(时间戳记第 k 个 sample);空位也必须连续 k 个 sample,才发 end。k=1 就退化成 Part 1(减去 end_time 关闭)。

文章配图

图 3 · Part 2 去噪:k=2 时,昙花一现的 handle 被过滤;flush 连续出现 2 次才确认 start,空位连续 2 次才确认 end

最容易错的点:frame 的身份沿 LCP 链追踪,不看名字。["req","validate"] → ["worker","validate","req"]:第 0 位就对不上(LCP=0),老栈两个 frame 全 end,新栈三个全是新 frame——即使 validate 还在第 1 位、req 跑到了第 2 位。链断了就是断了,名字相同也不认。

例子(k=2):[svc] 连续出现 2 次 → start 记在第 2 个 sample;中间冒出来一次的 handle(只出现 1 个 sample)直接忽略,不产生任何事件。

Part 2 参考代码

复用 Part 1 的 Sample / Event / _lcp_len 定义:

def stream_debounced_events(samples, k):
    """流式去噪:O(depth) 状态,事件一旦确定就 yield。"""
    depths = []   # 每层状态: live=已确认的frame, absent=空位连续计数,
                  #           cand=候选frame, run=占据连续计数
    prev = []

    def slot(d):
        while len(depths) <= d:
            depths.append({"live": None, "absent": 0, "cand": None, "run": 0})
        return depths[d]

    for s in samples:
        st = s.stack
        p = _lcp_len(prev, st)
        ends, starts = [], []
        for d in range(max(len(prev), len(st), len(depths))):
            cur = st[d] if d < len(st) else None
            sl = slot(d)
            if d < p:
                # 链没断:上一轮的 occupant 继续
                if sl["live"] == cur:
                    sl["absent"] = 0
                else:
                    if sl["live"] is not None:        # 旧 frame 继续 vacant
                        sl["absent"] += 1
                        if sl["absent"] == k:
                            ends.append(Event("end", s.ts, sl["live"], d))
                            sl["live"], sl["absent"] = None, 0
                    if sl["cand"] == cur:             # candidate 继续计数
                        sl["run"] += 1
                        if sl["run"] == k:
                            starts.append(Event("start", s.ts, cur, d))
                            sl["live"], sl["cand"] = cur, None
                            sl["absent"], sl["run"] = 0, 0
            else:
                # 链断了:这一层全是新 frame
                if sl["live"] is not None:
                    sl["absent"] += 1
                    if sl["absent"] == k:
                        ends.append(Event("end", s.ts, sl["live"], d))
                        sl["live"], sl["absent"] = None, 0
                sl["cand"], sl["run"] = None, 0       # 旧 candidate 静默死亡
                if cur is not None:
                    sl["cand"], sl["run"] = cur, 1
                    if k == 1:
                        starts.append(Event("start", s.ts, cur, d))
                        sl["live"], sl["cand"] = cur, None
        for e in sorted(ends, key=lambda e: -e.depth):    # 同 ts:end 从内到外
            yield e
        for e in sorted(starts, key=lambda e: e.depth):  # 同 ts:start 从外到内
            yield e
        prev = st

Part 3:后缀匹配(crash trace 归因)

TraceIndex:预先建好一批完整调用栈(entry → 出错点),给一个不完整 trace 的尾部(最后几帧),找出所有"尾部完全一样"的已知 trace。

  • match_suffix(["parse","oom"]) → 所有以 parse,oom 结尾的 trace id,字典序排列

  • 谁都不匹配 → 返回字符串 "UNKNOWN"(不是空 list)

  • 已知 trace 比 query 短也算不匹配

本质:后缀 trie。把每条 trace 反转后建成 trie(后缀变前缀),query 也反转,从根往下走,收集子树下所有 trace id。

文章配图

图 4 · Part 3 后缀 trie:每条 trace 反转后插入(后缀变前缀);query 也反转成 [oom, parse] 从根往下走, 走到 parse 节点后收集其子树 → T1、T2;蓝色节点 = 完整 trace 的终点

Part 3 参考代码

class TraceIndex:
    def __init__(self, traces):
        self.trie = {}
        for tid, frames in traces:
            node = self.trie
            for f in reversed(frames):     # 反转:后缀变前缀
                node = node.setdefault(f, {})
            node.setdefault("$ids", []).append(tid)

    def match_suffix(self, frames):
        node = self.trie
        for f in reversed(frames):
            if f not in node:
                return "UNKNOWN"
            node = node[f]
        out, stack = [], [node]
        while stack:                        # 收集子树下所有 trace id
            nd = stack.pop()
            out.extend(nd.get("$ids", []))
            stack.extend(v for k, v in nd.items() if k != "$ids")
        return sorted(out) if out else "UNKNOWN"

解法要点

文章配图

Part 1 的顺序是灵魂:end 从内到外(被调者先返回),start 从外到内(调用者先入栈)。写反了,事件流在语义上就是错的——面试官一眼能看出来。

备考清单

  • 先讲清楚"为什么这个 sample 说明函数结束了"再写代码——面试官看重这个理解

  • Part 1 顺序:end 内→外,start 外→内;frame 身份 = (depth, name)

  • Part 2:frame 身份看链不看名;k=1 退化成 Part 1;O(depth) 状态是硬要求

  • Part 3:反转 trie;"UNKNOWN" 是字符串不是空 list;结果字典序

  • 55 分钟 3 parts,Part 1 是地基,先写对再往后走

全文完
查看原文

沿着这个主题继续读

从一篇文章,找到相关的教程、经验和阅读路径。

AI 岗位与求职AI 时代的新岗位与求职:FDE、Agent 工程师、AI 产品经理的能力要求和面试准备。