自己摸了 2 个月零 offer,补底子只用了 3 块:Go 后端转 AI 最难的不是技术

7 阅读11分钟

自己摸了 2 个月零 offer,补底子只用了 3 块:Go 后端转 AI 最难的不是技术

裸辞转 AI 的人里,很多人卡在同一个地方:花两个月补的是「AI」,面试官花十分钟看的是「底子」。

近一段时间我带的转岗学员里,裸辞之后两个月拿不到 offer 的不止一个。复盘下来,他们的缺口高度一致,而且都不是简历上能看出来的那部分。

模拟面试里最常出现的一幕是这样的。

我问:「你做了 6 年 Go,现在想转 AI,那你最近一次面试,面试官问的第一个技术问题是什么?」

对方通常会先愣一下,然后说:「问得挺杂的,我也记不太清了。」

记不清本身就是答案。一个准备了两个月的人,如果连被问了什么都复述不出来,说明这两个月的准备没有落在能被追问的地方。

我带过的学员里有一位情况最典型:6 年 Go,地产 SaaS 背景,普通本科,主动裸辞出来转 AI。裸辞之后的两个月,招聘平台上打招呼的人不少,真正能约到面试的没几家,面完的基本都是回去等通知。

他自己给的结论是,自己零 AI 经验,岗位不匹配。

我听完他复述的那三分钟面试,给出的判断是另一回事:卡住他的不是简历,是简历下面那三块底子。

一、他把四个问题当成了一个问题

裸辞两个月零 offer,最容易让人归错因。他归的是「没有 AI 经验」,我拆出来的是四层,而且这四层的补法完全不同。

卡点他自己的判断面试官实际在看什么他后来具体补了什么
业务匹配度低以前做地产 SaaS,跟 AI 不搭边你过去解决的那类问题,在我这儿还成不成立把交易链路那一段改写成幂等与回滚的约束,并点明它在多轮编排里同样成立
零 AI 经验没做过真正的 AI 项目你那个项目能不能扛住三层追问没有换项目,就在原来那个问答机器人上补超时、重试、trace
项目细节挖不深做过的东西讲不出来讲到第二层,你是真做过还是在背每一步都带上耗时和重试次数,追问到第三层还有东西可讲
面试技巧不足一紧张就答乱数字有没有口径,结论有没有边界每个数字配一句测法,说不出测法的直接从简历里删掉

这四层里,第二层和第三层是同一件事的两面,所以我给他的清单是三块底子,不是四条建议。

二、底子一:业务匹配度,不是「我学过」,是「我做过的事在你这儿等于什么」

他简历上最显眼的一句,大意是负责地产 SaaS 核心交易链路的后端开发。这句在他原来的行业里是好句子,到了 AI 岗的面试官眼里,是一句跟他要面的岗位无关的话。

问题不在于他没做过事,在于他没有做过翻译。

地产 SaaS 的交易链路,本质上就是一件 AI 岗很认的事:多环节、有状态、不能重跑、出错要能回滚。这不就是现在做 Agent 编排时最难的那部分吗?工具调用要幂等,多轮对话要保持状态,失败要能定位到具体哪一步,这些他干了六年。

翻译这件事有固定三步,不靠灵感:

第一步,把项目里的业务名词剥掉,只留约束。地产 SaaS 是业务名词,多环节、有状态、不能重跑、要能回滚才是约束。

第二步,把约束翻译成目标岗位的行话。多环节对应编排,有状态对应会话与记忆,不能重跑对应幂等,要能回滚对应失败补偿。

第三步,补一句这个约束在 AI 项目里会怎么坑人。比如多环节有状态链路最常见的问题是:第二步失败了,第一步已经写进库里的数据怎么办。这句话一出来,面试官就知道他不是来背概念的。

三步做完,一个老项目就能讲出新东西,而且不需要凭空造 AI 经验。

这里要插一句反面例子,因为它最能说明「归错因」有多贵。另一位学员是 8 年前端转 AI Agent,简历改完之后约面反馈很好,但面一个挂一个。他的第一反应是简历还不够漂亮,于是又改了两版,结果还是挂。真正的原因跟他改简历这件事一点关系都没有:面试官问「你这个 Agent 的请求链路怎么走」,他只能讲到调框架那一层,再往下就空了。

一个人以为自己缺 AI 项目,一个人以为自己缺简历包装,两个人都在补最上面那一层,缺口都在下面。

三、底子二:项目深度,一个能被追问三层的小项目,胜过三个跑不通的大项目

这是最花时间的一块,也是他真正缺的那一块。

他自学阶段做的是「接一个大模型 API,做个问答机器人」。这类项目的问题不是简单,是它没有地方可以被追问。面试官问请求链路怎么走,答到「调 API、拿结果、返回前端」就到头了;再问失败怎么退,答加个重试;再问重试几次、超时多久、依据是什么,就卡住。

我让他不要换大项目,就在原来那个问答机器人上加三样东西:超时、重试、trace。加完之后,同一个项目能讲的东西完全不一样了。

以下代码需 Go 1.21+(用到标准库 log/slog)。

package main

import (
	"context"
	"errors"
	"fmt"
	"log/slog"
	"time"
)

type Step struct {
	Name    string
	Budget  time.Duration // 这一步的总预算,所有尝试加起来不超过它
	Attempt time.Duration // 单次尝试的超时,必须明显小于 Budget
	Retry   int
	Backoff time.Duration
}

func (s Step) Run(ctx context.Context, traceID string, fn func(context.Context) (string, error)) (string, error) {
	// 总预算建在循环外面:面试官问「这一步最多等多久」,答案是一个数,不是一次乘法
	ctx, cancel := context.WithTimeout(ctx, s.Budget)
	defer cancel()

	var lastErr error
	for i := 0; i <= s.Retry; i++ {
		start := time.Now()
		// 单次超时建在循环里面:只吃掉这一次尝试的时间,剩下的预算留给重试
		actx, acancel := context.WithTimeout(ctx, s.Attempt)
		out, err := fn(actx)
		acancel()

		args := []any{
			slog.String("trace_id", traceID),
			slog.String("step", s.Name),
			slog.Int("attempt", i+1),
			slog.Duration("elapsed", time.Since(start)),
		}
		if err == nil {
			slog.Info("step ok", args...)
			return out, nil
		}
		lastErr = err
		// 只有「单次超时且总预算还没用完」才值得重试
		if errors.Is(err, context.DeadlineExceeded) && ctx.Err() == nil {
			slog.Warn("attempt timeout", append(args, slog.String("err", err.Error()))...)
			if i == s.Retry {
				break
			}
			// 退避:立刻重试只会把一次抖动放大成连续两次超时
			select {
			case <-ctx.Done():
				return "", fmt.Errorf("step %s out of budget: %w", s.Name, ctx.Err())
			case <-time.After(s.Backoff):
			}
			continue
		}
		// 参数错、鉴权错这类错误重试多少次都是同一个结果,只会放大调用量。
		// 真实项目里还要把限流和 5xx 单独识别出来,它们属于可重试。
		slog.Error("step failed", append(args, slog.String("err", err.Error()))...)
		return "", err
	}
	return "", fmt.Errorf("step %s failed after %d attempts: %w", s.Name, s.Retry+1, lastErr)
}

func main() {
	// 整条链路先给一个总预算,再往下分给每一步
	ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
	defer cancel()

	// 一个最小的问答调用链:先检索,再让模型回答
	retrieval := Step{Name: "retrieval", Budget: 2 * time.Second, Attempt: 800 * time.Millisecond, Retry: 1, Backoff: 100 * time.Millisecond}
	model := Step{Name: "model", Budget: 20 * time.Second, Attempt: 8 * time.Second, Retry: 1, Backoff: 200 * time.Millisecond}

	traceID := "demo-0001"

	chunks, err := retrieval.Run(ctx, traceID, func(ctx context.Context) (string, error) {
		// 示例实现:真实项目里这里是向量召回
		select {
		case <-ctx.Done():
			return "", ctx.Err()
		case <-time.After(200 * time.Millisecond):
			return "top3 chunks", nil
		}
	})
	if err != nil {
		fmt.Println("retrieval failed:", err)
		return
	}

	answer, err := model.Run(ctx, traceID, func(ctx context.Context) (string, error) {
		// 示例实现:真实项目里这里是流式模型调用
		select {
		case <-ctx.Done():
			return "", ctx.Err()
		case <-time.After(300 * time.Millisecond):
			return "answer with " + chunks, nil
		}
	})
	fmt.Println(answer, err)
}

这段代码本身不值钱,值钱的是它带来的三个能讲的点。

第一,超时是两层,不是一层。这里说的两层是单个步骤内部的:单次尝试有自己的超时,整步有总预算,重试花掉的是总预算里剩下的部分。整条链路还另外有一个总预算,上面代码里是 25 秒,往下再分给每一步。所以预算和重试次数不能各拍各的,它们之间有个不等式:总预算要大于等于「单次超时乘以尝试次数」加上「退避乘以重试次数」。上面示例里检索是 2 秒对 800 毫秒乘 2 加 100 毫秒,模型是 20 秒对 8 秒乘 2 加 200 毫秒,两边都留了余量。这几个数是示例,你自己的项目要按自己的耗时分布重算。

第二,每个步骤的超时只跟这一步自己的耗时分布有关,上下游之间没有大小关系。检索 800 毫秒、模型 8 秒,是因为模型调用本身比检索慢一个量级,不是因为它在后面。常见的定法是取该步骤耗时分布的 P95 再留一倍余量。

第三,每一步都带 trace 字段和耗时。线上出问题时,你能回答是哪一轮、哪一步、耗时多少、重试了几次,而不是回答我看看日志。

这三句话讲出来,面试官就知道你是真做过,不是看过。

四、底子三:面试表达,每个数字都要准备一句口径

这一块最容易被忽略,也最容易翻车。

简历上写着「优化后响应速度明显提升」,我通常会问一句:这个提升是怎么测出来的?对方答不上来,最后落在「就是感觉快了」。

一句话就把前面所有的准备都打折了。他的问题也属于这一类。

后来我给他定的规则是:简历上每一个数字,都必须配一句怎么测出来的,说不出来的数字就从简历里删掉。

一个能用的数字,至少经得起下面三句追问,每一句都要提前准备好:

这个数是在什么环境上测的。本地单机、测试环境、还是线上灰度,三者的结论差得远。

测的时候用的什么数据、多少条。比如几十条手挑的样本和几千条真实分布,得出来的数不是一个东西。

有没有对照。没有对照组的提升等于没有提升,你连跟谁比都说不清,这个数就不该写进简历。

三句话准备完,你会发现简历上能留下的数字不多,但留下来的每一个都能扛住追问。这比写满一页纸强。

还有一条值得单独说:模拟面试不是走流程,是找缺口。我让他每次结束之后立刻把面试官问倒他的那一层原样记下来,下一次从那一层开始准备。这里说的层,指的是同一个问题往下追的深度:第一层是你做了什么,第二层是怎么做的,第三层是这么定的依据是什么,第四层是出了问题你怎么定位。两个月下来,他一开始答到第二层就空了,后来能扛住连续三层。这个变化在简历上是看不出来的。

五、两个月之后发生了什么

这三块底子的顺序不能乱。业务匹配度解决的是能不能被看见,项目深度解决的是能不能扛住问,面试表达解决的是讲出来的东西有没有说服力。先补第三块没有意义,因为前两块没有内容可讲。

那位 8 年前端的学员也是这样,他停掉改简历、回头去补链路细节之后,offer 才接连来。

他按这个顺序补完之后,变化来得比他预想的快。原本他的期望值定得挺保守,觉得能拿个跟原来差不多的档位就不错了。结果后面一段时间里 offer 接连来了几个,最后去的那家,给到的比他原本的预期高出一截。

入职那天他给我发消息,说的还是这件事。

六、如果你也在裸辞转 AI 的路上

我想说一句可能会得罪人的话:两个月零 offer 这个信号,指向的通常不是这个赛道不行,也不是你不适合,而是你准备的东西跟面试官要考察的东西不在同一层。

你可以先做一次自我检查,不用找人:

把最近一次面试里被问到的三个问题写下来。如果写不出来,说明你的准备还没落到能被追问的层面,先补第二块底子。

如果写得出来,就对着每一个问题往下再问自己两层。答不到第二层的,就是你的缺口所在。

这个动作花不了半小时,但比再投五十份简历有用得多。


你最近一次面试,是被问到哪一层就答不下去了?评论区聊聊。