最近在脉脉上看到很多快手同学的年终奖爆料,我整理了一波研发岗的真实数据。不仅有具体同学的爆料明细,还汇总了各职级的统计数据,一起来看看快手研发的年终奖水平到底如何。
先看一波同学的真实爆料,不同部门、不同职级的奖金和月数都在这儿,真实度拉满:

熟悉快手的朋友应该知道,26届校招快手给的是16薪结构,折算下来就是4个月年终奖。从这次的爆料数据能明显看出来,研发岗拿到4个月年终奖的同学不在少数,当然也有不少人能拿到远超4个月的奖金,天花板直接拉满。

上面这些只是部分明细,我前后累计收集了60+条快手研发岗的有效年终奖数据,按职级做了详细的月数统计,中基层和高职级的差异一眼就能看出来:

从月数能看出快手研发岗的奖金发放规律特别清晰,中基层的标准化程度很高:E6、E7、E8这三个核心中基层职级,中位数月数都是4个月,刚好匹配校招的16薪基础结构,说明大部分普通研发同学,都能拿到这个基础的奖金月数,保底很稳。
而E9职级算是一个明显的「分水岭」,中位数月数涨到了5个月,平均月数5.13个月,和中位数几乎持平,意味着这个职级超半数的同学,都能拿到5个月的奖金,是从中基层往高职级走的一个重要过渡信号。
到了E10职级,奖金月数直接上了一个台阶,中位数月数7.5个月,甚至比平均月数还高,说明超半数E10同学能拿到7.5个月及以上的奖金,高职级在奖金时长上的优势直接拉满。至于E11,虽然这次只有1个样本,但9.5个月的月数直接刷新了天花板,妥妥的顶级激励水平。
聊完月数,再看大家最关心的奖金金额,先上各职级的奖金统计明细:

整体看下来,快手研发岗的奖金分布特别实在,没有那种极端的贫富差距:超五成研发同学的奖金集中在10-20万这个核心区间,20万以上的高奖金人群也占了三成,真正低于10万的只有16%左右。
快手年终奖的事情就聊到这,又到了学习面经的时刻了,这次我们看点不一样的。
之前很多读者后台留言说有没有「AI应用开发」的面经,确实AI这一两年发展太快了。前两年后端面试还在死磕分布式、高并发,现在不管大厂小厂,招后端、招开发岗,都要顺带问两句AI相关的技术,甚至专门开了AI应用开发的岗位。
这里先跟大家掰扯清楚,AI应用开发岗本质还是开发岗,可不是算法岗!不用你从头训大模型、搞复杂的算法优化,核心考察的就是你能不能把大模型用起来,结合实际业务场景做出能落地、能解决真实问题的应用,这才是关键。
技术栈这块,主流还是用Python做开发,毕竟Python的LangChain这类AI框架更新迭代最快,岗位对这块的熟练度要求不低。同时还得吃透大模型开发的核心技术,比如Agent、RAG、向量数据库、LLM这些核心知识点,少一个都不行。
当然也有不少公司是在原有后端系统基础上做AI应用开发,这时候技术栈就看原有的体系了:如果是Java技术栈,就得熟Spring AI Alibaba;如果是Go技术栈,那字节内部都在广泛使用的Eino框架就得吃透。
今天重点给大家分享这份新鲜出炉的快手AI应用开发岗面经,整场面试时长40分钟。考点特别有代表性,主要考察了Python、LangChain、LangGraph、RAG、Function Call、HTTP协议这些核心知识点,经典的手撕算法环节也没缺席,妥妥的大厂面试标配。

快手(AI应用开发)
1. Python中列表和元组的区别是什么?
列表和元组最核心的区别就是可变性不同。
- 列表是可变的,创建之后可以随意增删改元素;
- 元组是不可变的,一旦创建就不能修改。
比如:
# 列表可以修改
my_list = [1, 2, 3]
my_list.append(4) # 可以添加
my_list[0] = 10 # 可以修改
my_list.remove(2) # 可以删除
# 元组不可修改
my_tuple = (1, 2, 3)
my_tuple[0] = 10 # 会报错:TypeError
my_tuple.append(4) # 元组没有这个方法
因为这个特性,它们的使用场景就完全不一样。
- 列表适合存储会变化的数据集合,比如用户列表、购物车商品这些会动态增减的数据。
- 元组适合存储固定不变的数据,比如坐标点、数据库查询返回的一行记录、函数返回多个值的时候。
从性能上讲,元组因为不可变,所以创建和访问速度都比列表快一些,占用的内存也更小。Python在底层对元组做了优化,小的元组会被缓存重用。我测试过,创建同样的数据,元组大概快20%左右。如果数据量很大且不需要修改,用元组性能会更好。
还有个重要区别是可哈希性。元组是不可变的,所以是可哈希的,可以作为字典的key或者放到集合里;列表是可变的,不可哈希,不能当字典的key。比如:
# 元组可以作为字典的key
location_dict = {
(10, 20): "点A",
(30, 40): "点B"
}
# 列表不能作为key
error_dict = {
[10, 20]: "点A" # 会报错:TypeError: unhashable type: 'list'
}
# 元组可以放入集合
point_set = {(1, 2), (3, 4)}
# 列表不能放入集合
error_set = {[1, 2], [3, 4]} # 会报错
不过要注意一点,如果元组里包含可变对象,虽然元组本身不能修改,但里面的可变对象还是可以改的:
my_tuple = (1, 2, [3, 4])
my_tuple[2].append(5) # 这是可以的,元组里的列表被修改了
print(my_tuple) # (1, 2, [3, 4, 5])
从语法上看,列表用方括号 [] 定义,元组用圆括号 () 定义。特别要注意的是,定义只有一个元素的元组时必须加逗号,不然Python会把它当成普通的括号表达式:
single_tuple = (1,) # 这才是元组
not_tuple = (1) # 这只是整数1,不是元组
print(type(single_tuple)) # <class 'tuple'>
print(type(not_tuple)) # <class 'int'>
列表提供的方法更丰富,有append、extend、insert、remove、pop、sort、reverse这些方法。元组只有count和index两个方法,因为它本身就不能改。
my_list = [3, 1, 2, 1]
my_list.sort() # 排序
my_list.reverse() # 反转
my_tuple = (3, 1, 2, 1)
my_tuple.count(1) # 统计1出现的次数,返回2
my_tuple.index(2) # 查找2的位置,返回2
在实际项目里,如果数据需要保护不被意外修改,我会用元组。比如配置信息、常量定义这些。如果需要频繁操作数据,就用列表。还有个经验就是,函数返回多个值的时候,Python默认就是返回元组,这样既高效又能保证返回值不被修改:
def get_user_info():
return ("张三", 25, "北京") # 返回元组
name, age, city = get_user_info() # 解包
简单来说,列表灵活可变适合动态数据,元组不可变更安全高效适合固定数据,这就是它们的核心区别。
2. 介绍一下Python中的装饰器及其应用场景
装饰器本质上就是一个函数,作用是在不修改原函数代码的情况下给函数增加额外功能。它的实现原理就是利用了Python的高阶函数特性,函数可以接收函数作为参数,也可以返回函数。最简单的例子是:
def my_decorator(func):
def wrapper(*args, **kwargs):
print("函数执行前")
result = func(*args, **kwargs)
print("函数执行后")
return result
return wrapper
@my_decorator
def say_hello():
print("Hello!")
这里的 @my_decorator 就是语法糖,等价于 say_hello = my_decorator(say_hello)。装饰器接收原函数,返回一个包装后的新函数。
最常见的应用场景就这几个。第一个是日志记录,记录函数的调用信息和耗时:
def log_decorator(func):
def wrapper(*args, **kwargs):
print(f"调用 {func.__name__},参数: {args}")
result = func(*args, **kwargs)
print(f"返回值: {result}")
return result
return wrapper
第二个是权限验证,在Web开发中特别常用,比如检查用户是否登录:
def require_login(func):
def wrapper(*args, **kwargs):
if not user.is_logged_in:
return "请先登录"
return func(*args, **kwargs)
return wrapper
第三个是性能统计和缓存,避免重复计算:
def cache(func):
cached = {}
def wrapper(*args):
if args in cached:
return cached[args]
result = func(*args)
cached[args] = result
return result
return wrapper
实际项目中像Flask的路由 @app.route('/api') 就是装饰器,还有Django的 @login_required 验证登录也是。
有个要注意的点,使用装饰器后原函数的元信息会丢失,所以通常会加上 @wraps(func) 来保留函数名和文档字符串。如果装饰器本身需要参数,就要再加一层函数嵌套。
总的来说,装饰器就是通过闭包实现的代码复用机制,让代码更简洁优雅,在日志、权限、缓存这些横切关注点的场景特别适用。
3. LangGraph相比于LangChain有什么优势?
LangGraph最大的优势就是它能处理复杂的、非线性的工作流,而LangChain更适合简单的线性链式任务。

具体来说,LangChain是把LLM操作串成一条链,比如“检索数据→总结→回答问题”这种固定的顺序执行。但很多实际场景其实不是这么简单的,比如你要做一个智能助手,用户可能随时切换任务、要求返回上一步、或者根据结果决定下一步做什么,这种情况LangChain就不太好处理了。
LangGraph就是专门解决这个问题的。它用图结构来构建应用,每个操作是一个节点,节点之间通过边连接,可以自由跳转、循环、甚至回到之前的状态。

举个例子,如果你在做任务管理助手,用户可能说“添加任务”、“完成任务”、“查看任务列表”,这些操作的顺序是不固定的,LangGraph就能很灵活地处理这种动态交互。
# LangGraph的图结构示例
from langgraph.graph import StateGraph
# 定义状态
class TaskState(TypedDict):
tasks: List[str]
user_input: str
# 创建图
graph = StateGraph(TaskState)
graph.add_node("process_input", process_input_node)
graph.add_node("add_task", add_task_node)
graph.add_node("complete_task", complete_task_node)
graph.add_edge("process_input", "add_task")
graph.add_edge("add_task", "process_input") # 可以循环回去
第二个关键优势是状态管理。LangChain虽然能在链条里传递数据,但不太容易维护跨多次运行的持久状态。LangGraph把状态作为核心组件,所有节点都能访问和修改状态,这对于需要记住上下文的应用特别重要。比如多轮对话的虚拟助手,需要记住之前聊了什么,LangGraph处理起来就很自然。
第三个优势是可视化和调试更容易。因为是图结构,你可以很清楚地看到整个应用的流程,每个节点在做什么、状态怎么变化的。LangGraph还提供了Studio这种可视化界面,让开发和调试更直观。
从使用场景来说,如果你的需求是明确的顺序任务,比如数据抓取→处理→输出报告,那LangChain更简单直接。但如果是多智能体系统、需要长时间保持上下文的对话系统、或者工作流会根据条件动态调整的复杂应用,LangGraph就更合适。
实际上LangGraph是建立在LangChain基础上的,它依赖 langchain-core,可以看作是LangChain生态的一个专门扩展。
所以不是替代关系,而是针对不同复杂度的问题选择合适的工具。简单说就是,LangChain适合线性流程,LangGraph适合需要“思考和决策”的复杂场景。
4. LangGraph的状态快照机制了解吗,大概是怎么实现的?
了解的。LangGraph的状态快照机制其实就是检查点持久化(Checkpointing),核心思想是在图执行的每一步都把当前状态保存下来,这样就能实现暂停、恢复、甚至回溯到之前的某个状态。
具体实现上,它是通过Checkpointer这个组件来做的。你在编译图的时候传入一个checkpointer,比如 MemorySaver 或者 RedisSaver,然后每执行完一个节点,LangGraph就会自动调用它把当前状态序列化存储起来。
存储的key是由 thread_id 和 thread_ts 组成的,thread_id 用来区分不同的会话或用户,thread_ts 是时间戳,标识具体是哪个执行步骤。
from langgraph.checkpoint.memory import MemorySaver
memory = MemorySaver()
graph = graph_builder.compile(checkpointer=memory)
# 执行时会自动保存每一步的状态
graph.invoke(input, config={"configurable": {"thread_id": "user_123"}})
每个快照里包含的内容主要有三部分:
- 一个是
values,就是当前状态的所有变量值,比如消息列表、用户输入这些;
- 第二个是
next,记录下一步要执行哪个节点;
- 第三个是
config,包含会话ID和时间戳这些元信息。
这个机制最实用的地方是支持状态恢复和回溯。你可以通过 get_state() 拿到当前状态,用 get_state_history() 查看整个执行历史。更强大的是,你可以用 update_state() 在任意历史节点修改状态,然后从那个点重新执行,这对于人机协作特别有用,比如用户说“刚才那步不对,重新来”,你就能回到之前的状态进行修正。
# 获取当前状态
snapshot = graph.get_state(config)
print(snapshot.values) # 当前所有状态变量
print(snapshot.next) # 下一步执行的节点
# 查看历史
for state in graph.get_state_history(config):
print(state.config["configurable"]["thread_ts"]) # 时间戳
print(state.values) # 那个时刻的状态
# 修改某个历史状态并继续执行
graph.update_state(past_config, values={"messages": [new_message]})
另外一个关键点是Reducer函数的配合使用。因为状态是增量更新的,比如每次对话要往消息列表里添加新消息,你需要定义一个合并策略。LangGraph通过 Annotated 类型注解来指定,比如 Annotated[list, add_messages],这样每次节点返回新消息时,系统就知道该怎么把新消息和旧消息合并到一起。
from typing import Annotated
from langgraph.graph.message import add_messages
class State(TypedDict):
messages: Annotated[list, add_messages] # 自动追加消息
实际应用场景就是多轮对话、长时间运行的任务、还有需要人工介入的工作流。比如一个客服机器人,用户可能今天聊一半明天再继续,或者AI给出的回复不满意要人工修改后重新生成,这些都依赖状态快照机制来实现。每个用户的对话历史通过 thread_id 隔离,互不干扰,存储后端可以根据需求选择内存、Redis或者数据库。
简单总结就是,LangGraph通过在每个节点执行后自动保存状态快照,结合thread_id做会话隔离、用Reducer做状态合并,实现了可恢复、可回溯、支持人机协作的状态管理机制。这是它能处理复杂长流程应用的基础能力。
5. 如何区分短期记忆和长期记忆?
短期记忆和长期记忆主要是从存储范围和生命周期这两个维度来区分的。

短期记忆
短期记忆其实就是单次对话会话内的记忆,它的作用域是在一个thread或者说一个对话线程里面。比如用户这次跟AI聊天,问了几个问题,AI需要记住前面说了什么才能理解后续的问题,这就是短期记忆在起作用。
在LangGraph里,短期记忆是通过状态管理和检查点来实现的,每个节点执行完都会把当前状态保存下来,包括消息历史、节点执行进度这些信息。这种记忆的特点是只在当前会话有效,对话结束或者系统重启后,如果没有持久化,这些记忆就丢失了。
长期记忆
长期记忆则是跨会话、跨对话的持久化记忆。它不限于某一次对话,而是可以在不同的thread之间共享。典型的应用场景就是记住用户的偏好设置、历史交互习惯这些信息。
比如用户上周告诉AI他喜欢听摇滚乐,这周再来聊天时,AI还能记得这个偏好并据此推荐音乐,这就依赖长期记忆。
在LangGraph中,长期记忆是通过Store存储来实现的,通常会用namespace和key来组织数据,比如 ["user_123", "preferences"] 这样的命名空间结构。存储后端可以选择Redis、PostgreSQL这些持久化的数据库。
技术实现区别
从技术实现角度讲,在LangGraph里,短期记忆用的是 MemorySaver 这种checkpointer,它会把每一步的State快照保存起来,通过 thread_id 来区分不同的对话。
而长期记忆用的是 InMemoryStore 或者其他持久化Store,通过自定义的namespace来组织信息,可以跨thread访问。
举个具体例子,如果你要做一个智能客服,用户这次咨询过程中的对话历史是短期记忆,但用户的会员等级、购买偏好这些信息就应该放在长期记忆里,这样下次用户再来咨询时,不用重新问一遍这些基本信息。
简单总结就是,短期记忆解决的是“这次对话中的上下文连贯性”问题,作用域是单个thread;长期记忆解决的是“跨对话的个性化和知识积累”问题,作用域是跨多个thread甚至全局。在实际开发中,两者通常是配合使用的,短期记忆保证对话流畅,长期记忆提供个性化体验。
6. 安全护栏是如何实现敏感词拦截的?
安全护栏实现敏感词拦截主要有两种技术路线,一种是基于规则的字符串匹配,另一种是基于AI模型的语义理解。
字符串匹配的方式
先说字符串匹配这种方式,它的实现比较直接。系统会维护一个敏感词词库,可以是内置的也可以是自定义的。当请求进来的时候,会遍历这个词库,对输入内容进行逐个匹配。匹配方式又分两种,一种是STR模式,就是简单的字符串包含检查,比如 substring in text 这样;另一种是WORD模式,用正则表达式的单词边界来匹配,避免误杀,比如你要拦截“政治”这个词,但不想拦截“政治学”,就可以用 \b政治\b 这种边界匹配。
这种方式的优点是简单高效性能好,实时性强,适合对延迟要求高的场景。但缺点也明显,就是容易被绕过,比如用户输入“政 治”加个空格,或者“zheng zhi”加特殊符号,纯字符串匹配就识别不出来了。而且它不理解语义,可能会有误判。
基于 AI 模型的方式
然后是基于AI模型的方式,这个会更智能一些。它用的是Zero-Shot Classification(零样本分类)技术,核心是用预训练的Transformer模型,比如RoBERTa或者DeBERTa这些,对输入内容进行主题分类。
你不需要针对每个敏感主题单独训练模型,直接用预训练模型就能判断输入是不是涉及暴力、政治、色情这些禁止的主题。模型会给每个主题打一个概率分数,然后你设定一个阈值,比如0.6,如果最高概率超过这个阈值就触发拦截。
这种方式的好处是理解语义,能识别出变换表达方式的敏感内容,绕过的难度更大。而且可以用多语言模型,比如支持中文的BGE-M3模型,对中文场景也能覆盖。缺点就是计算成本相对高一些,延迟会大一点,但可以通过调整模型和阈值来平衡准确率和性能。
实际应用中,通常会把两种方式结合起来用,形成多层防护。比如先用字符串匹配快速过滤掉明显的敏感词,然后再用AI模型检测那些变换过表达方式的内容。同时还会配合其他检测能力,像提示词攻击检测、恶意URL检测这些,构建一个完整的安全护栏体系。
7. 在RAG中,文档切片的粒度如何选择?
文档切片粒度的选择主要需要权衡三个因素:语义完整性、检索准确率和模型限制。

首先要考虑的是模型的技术限制。Chunk Size不能超过Embedding模型的Max Token限制,比如你用的是BAAI/bge-large-zh-v1.5这种模型,它可能有512或1024的token限制。同时还要考虑大模型的上下文窗口,因为实际应用中你可能检索Top 5或Top 10个chunk,把它们拼在一起传给大模型,所以需要确保 Top k × Chunk Size 不超过大模型的Max sequence length。
从实践角度讲,粒度大小没有绝对标准,要根据文档类型和业务场景来定。一般来说,如果文档内容比较长且逻辑连贯性强,比如技术文档或法律合同,可以用稍大的粒度,比如500-800个token,这样能保持更完整的上下文。如果是问答型或者短文本场景,用200-300个token的小粒度会更精准,避免检索到太多无关内容。
技术实现上,我会推荐递归分块结合语义分割的策略。具体做法是先用RecursiveCharacterTextSplitter按照层次化的分隔符来分割,比如先按段落 \n\n,再按句子 。 这样分,优先保证语义完整性。然后在这个基础上做语义切分的优化,就是用滑动窗口的方式计算相邻chunk的embedding相似度,如果相似度高就合并,相似度低就切开,这样能确保每个chunk内部语义连贯,chunk之间主题区分明显。
另外一个关键点是设置Overlap重叠区域。我一般会设置10%-20%的重叠,比如chunk size是500,overlap就设50-100。这样做的好处是当关键信息刚好在chunk边界的时候,通过重叠能确保不会遗漏。特别是用户的问题可能横跨两个chunk的内容时,有重叠就能提高召回率。
还有一个实用的技巧是根据文档结构来分。如果是Markdown或者PDF这种结构化文档,可以用Document Specific Splitting,按照章节、标题、段落这些天然的结构来切,而不是机械地按字符数切。这样切出来的chunk更符合人的阅读习惯,检索效果也会更好。
最后就是要实际测试和调优。我的做法通常是先用一个baseline的设置,比如chunk size 500、overlap 50,然后在真实业务场景下跑测试集,看检索的准确率和召回率。如果发现很多问题答不上来,可能是chunk太大了导致噪音多,就调小一点;如果发现答案被切断了,就调大或者增加overlap。同时可以统计一下每个chunk的实际token数分布,确保没有超出模型限制。
简单总结就是,文档切片粒度没有万能公式,需要综合考虑模型限制、文档类型、业务场景,推荐用递归语义分块加适当overlap的策略,然后在真实数据上迭代调优找到最佳配置。
8. 向量数据库索引中,IVF_FLAT和HNSW有什么区别?各自的适用场景?
IVF_FLAT和HNSW的核心区别在于索引结构和查询策略的不同。

IVF_FLAT
先说IVF_FLAT,它的全称是倒排文件加扁平索引。它的原理是通过K-means聚类把整个向量空间划分成很多个小区域,每个区域就是一个聚类中心,然后把属于这个区域的向量都放到对应的倒排列表里。

查询的时候,先通过量化器找到离查询向量最近的几个聚类中心,比如找最近的10个cluster,然后只在这10个cluster里面做暴力搜索。这样就把搜索范围大大缩小了,不用遍历全部向量。
IVF_FLAT的特点是构建速度快,内存占用低,召回率也比较高,接近暴力搜索的水平。但搜索速度属于中等水平,因为在每个cluster内部还是要做暴力计算。它比较适合中小规模的向量库,比如百万到千万级别的数据量,而且对召回率要求比较高的场景。
HNSW
HNSW的全称是分层导航小世界网络,它用的是完全不同的思路。它构建了一个多层的图结构,上层图只包含少量的节点作为路标,下层图包含所有的节点。每层图里的节点连接都遵循小世界特性,也就是说节点之间有一些短路径的连接。查询的时候从最顶层开始,快速定位到大概区域,然后逐层向下细化,最终在最底层找到最相似的向量。

HNSW的最大优势是搜索速度极快,特别适合大规模向量库,比如千万甚至上亿级别的数据。查询延迟可以做到10毫秒以内,QPS能达到万级。召回率也很高,接近暴力搜索。但它的缺点是构建成本高,内存占用大,大概需要原始向量1.5到2倍的内存空间。
各自的适用场景
从适用场景来讲,如果是小规模向量库,比如一百万以内的数据,或者对召回率要求非常严格的场景,用IVF_FLAT比较合适,它能在速度和召回率之间取得比较好的平衡,而且资源消耗相对小。
如果是大规模高并发场景,比如实时推荐系统、语义搜索引擎这种需要毫秒级响应的应用,那HNSW是首选。虽然它内存占用大,但可以通过调整参数来优化,比如调整M参数控制每个节点的最大连接数,或者调整efSearch参数控制搜索时的范围。
9. 什么是CoT?为什么它能提高模型处理复杂任务的能力?
什么是 CoT?
CoT的全称是Chain of Thought,中文叫思维链,是Google Research在2022年提出的一种提示工程技术。它的核心思想很简单,就是让模型不要直接给答案,而是先一步步展示推理过程,再得出最终结果,模仿的是人类解决复杂问题时的自然思维方式。
举个例子,如果问模型“小明有5个苹果,吃了2个,又买了3个,现在有几个?”传统方式模型可能直接输出“6个”。
但用CoT的话,模型会先说“小明最初有5个苹果,吃了2个后剩下5-2=3个,然后买了3个,所以3+3=6个,最终答案是6个”。虽然结果一样,但在复杂问题上,这种分步推理能显著提升准确率。Google的实验显示,在数学推理任务GSM8K上,使用CoT的PaLM模型准确率直接从33%跃升到58%,提升非常明显。
那为什么CoT能提高模型处理复杂任务的能力呢?
主要有几个原因。
- 首先是将复杂问题分解了。传统方式模型要一步到位从问题跳到答案,这对多步推理的任务难度太高,容易出错。CoT把一个复杂的推理任务拆成多个中间步骤,每一步处理一小部分问题,就像我们做数学题要列出解题过程一样,这样每个步骤的难度降低了,整体准确率自然提升。
- 其次是暴露了推理路径。传统模型是个黑盒,你看不到它内部是怎么思考的,错在哪一步也不知道。CoT把中间推理步骤全部显式生成出来,如果某一步算错了,你能直接看到,也方便后续修正。这就相当于给模型创建了一个“外部工作记忆”,类似人类思考时的草稿纸,可以把复杂信息暂存并逐步处理。
- 第三是激活了模型的隐式推理能力。研究发现,当模型生成“因为...所以...”这类逻辑连接词时,Transformer的注意力机制会自动聚焦于相关的事实片段,形成一个临时的推理图谱。CoT本质上就是通过prompt引导,激发了大模型在预训练时学到但平时没充分利用的推理能力。这也解释了为什么模型越大CoT效果越好,因为大模型包含更丰富的隐式知识和推理路径。
- 第四是提供了自我验证的机制。分步推理后,模型可以在每个检查点进行验证,比如“这一步对吗?”这类元认知提示。甚至可以生成多条不同的推理路径,然后通过投票选出最一致的答案,这种Self-Consistency方法能进一步提升鲁棒性。
从认知科学的角度看,CoT其实是对人类工作记忆理论的精准模拟。人类解决复杂问题时,会将信息暂存在工作记忆中,通过“内部语言”进行分步处理。CoT为AI创建了类似的“数字工作记忆”,让模型像人一样思考。
当然CoT也不是万能的。它主要适用于需要多步推理的复杂任务,像数学计算、逻辑推理、多跳问答这些场景效果特别好。但对于简单的事实查询,比如“巴黎人口多少”,用CoT反而会增加计算成本和延迟,没什么收益。而且CoT通常需要模型参数超过100B才能有明显效果,小模型用CoT提升不大。
10. 大模型应用中常见的幻觉有哪些类型?在工程上如何缓解?
什么是幻觉?
在某些情况下,我们在使用大模型生成结果时,会有一个直观的感受,就是“一本正经的胡说八道”。

- 一本正经:生成结果流畅、困惑度 PPL 低、有逻辑性。
- 胡说八道:存在两种定义①内容与人类认知不一致;②内容不可证伪。
幻觉有哪些类型?
大模型幻觉主要可以分成几个类型。
- 最常见的是事实冲突型幻觉,就是生成的内容跟客观世界知识或者给定的参照知识相互矛盾,比如说你问它某个历史事件的时间,它可能会给你一个完全错误的年份,但说得特别自信。
- 第二种是无中生有型幻觉,这个比较严重,就是模型完全虚构了不存在的内容,像编造论文引用、虚构统计数据这些。我之前在一个金融项目里就遇到过,模型生成了一个“2023年Q3净利润增长200%”的假数据,差点引发合规问题。
- 第三种是指令误解型幻觉,就是模型没理解你的真实意图,生成的内容偏离了你的问题主题。
- 第四种是逻辑错误型幻觉,推理过程存在逻辑漏洞,比如因果关系搞错了。
怎么缓解幻觉?
从工程实践角度,缓解幻觉主要有这么几个方向。
- 第一个是RAG检索增强生成,这是目前最主流也最有效的方法。核心思路就是把大模型问答从“闭卷考试”变成“开卷考试”,让模型不再完全依赖它训练时学到的那些静态知识,而是在回答时先去你的知识库里检索相关信息,再基于检索到的上下文来生成答案。这样能大幅降低知识局限性和时效性导致的幻觉。不过RAG也不是万能的,如果检索到的信息本身有冲突或者不相关,还是可能产生幻觉,所以需要在检索质量上下功夫,比如用混合检索、重排序这些技术来提升召回的准确性。
- 第二个是提示工程优化,特别是CoT思维链提示。通过让模型一步步展示推理过程,能够降低直接跳跃式回答导致的错误。同时在Prompt里明确要求模型在不确定时要表达不确定性,或者直接拒绝回答,而不是强行编造答案,这个在实际应用中效果挺明显。
- 第三个是后验检测加验证机制,也就是在模型生成答案之后,再用一些技术手段去检测和验证。比较常用的有基于不确定性的检测,通过分析模型输出token的概率分布,如果模型对某个关键概念的预测概率很低,那幻觉风险就比较高。还有基于知识溯源的验证,把生成的陈述拆解成原子事实,然后逐一去外部知识库或者搜索引擎里验证,看看能不能找到证据支撑。
- 第四个是模型微调和对齐,通过RLHF人类反馈强化学习或者DPO直接偏好优化这些技术,在训练阶段就让模型学会区分事实和虚构,增强它的事实准确性。不过这个成本比较高,一般在对准确性要求特别严格的垂直领域才会这么做。
- 还有一个实用技巧是多路验证,就是让模型针对同一个问题生成多个答案,然后通过一致性检查来筛选,如果多次生成的答案逻辑不一致,那很可能是在编造,这种Self-Consistency的方法在复杂推理任务上挺有效。
最后从系统设计角度,建议建立分层防御机制,在数据预处理、检索召回、生成控制、后验验证这几个环节都加上防护措施,多管齐下才能把幻觉风险降到最低。同时要针对具体业务场景去调优,医疗、金融、法律这些高风险领域对准确性要求更严格,需要更严格的验证流程。
11. 介绍一下Function Call的流程,模型是如何知道该调用哪个工具的?
Function Call的工作流程其实是一个多轮交互的过程。简单来说分成这么几个核心步骤。
- 首先是发起第一次模型调用。应用程序会把用户的问题连同所有可调用的工具清单一起发给大模型。这个工具清单通常是用JSON Schema格式描述的,包括工具的名称、功能描述、需要什么参数这些信息。比如说你有一个查天气的工具,你会告诉模型“这个工具叫get_current_weather,它的作用是查询指定城市的天气,需要一个location参数,类型是字符串”。
- 接下来是模型判断阶段。大模型会分析用户的问题,然后对照你提供的工具清单,判断是否需要调用工具。如果需要,它会返回一个JSON格式的指令,里面包含了要调用哪个函数以及具体的参数值。如果模型判断不需要调用工具,就直接返回自然语言的回复。
那模型是如何知道该调用哪个工具的呢?
核心原理其实是通过提示工程和模板实现的。你在定义工具的时候给的那个description描述非常关键,模型会根据这个描述来理解什么时候应该用这个工具。
比如你写“当你想查询指定城市的天气时非常有用”,模型遇到天气相关的问题就知道该用这个工具了。

更具体一点,大模型是通过训练学会的这种能力。像Qwen3这类模型在预训练和微调阶段就学习过大量的函数调用样本,它们知道如何把自然语言需求映射到结构化的函数调用上。模型会分析用户query里的关键信息,比如“北京天气”这个问题,模型能识别出这是在问天气,地点是北京,然后自动提取出location参数填成“北京市”。
第三步是在应用端执行工具。模型只是告诉你要调哪个函数和参数,真正执行是在你的应用程序里完成的。你需要解析模型返回的JSON,拿到函数名和参数,然后在本地调用对应的函数获取结果。
第四步是发起第二次模型调用。拿到工具执行结果后,要把这个结果添加到消息上下文里,角色标记为tool或function,再次发给模型。这时候消息列表就包含了用户问题、模型的工具调用指令、还有工具返回的结果。
最后一步是模型总结。模型会把工具返回的结构化数据和用户原始问题整合起来,生成一个友好的自然语言回复。比如工具返回的是 {"temperature": 26.1, "location": "北京"} 这种JSON,模型会把它转成“北京今天的天气是26.1度”这样用户能看懂的话。
从技术实现角度讲,Function Call本质上是通过Chat模板来约束模型的输出格式。比如Hermes风格的工具调用模板会把工具信息嵌入到system message里,用特殊的XML标签或JSON Schema告诉模型可用工具的格式。模型在生成时会遵循这个模板,把工具调用请求输出成约定好的结构化格式。
12. 介绍一下vLLM中PagedAttention的原理
什么是PagedAttention?
PagedAttention是vLLM提出的一个核心技术,主要解决大模型推理过程中KV Cache内存管理的问题,设计灵感来源于操作系统的虚拟内存分页机制。
先说背景,大模型在生成文本时,每生成一个token都需要用到之前所有token的Key和Value向量来计算注意力。为了避免重复计算会把它们缓存起来,这就是KV Cache。
但传统方法有三个痛点:
- 一是显存占用随生成长度线性增长,比如OPT-13B生成2048个token就要1.6GB;
- 二是内存碎片严重,系统会按最大长度预分配连续内存,但很多请求实际只用一小部分,导致内存利用率最低只有20%;
- 三是KV Cache无法共享,像Parallel Sampling这种多个分支共享prompt的场景,只能冗余复制。
PagedAttention 实现思路是什么?
PagedAttention的核心思路就是借鉴操作系统的虚拟内存机制。它有三个关键设计。
第一是把KV Cache切分成固定大小的block,默认是16个token一个block,不再要求内存连续存放。
第二是通过block table实现逻辑块到物理块的映射。所有请求的KV数据存在统一的物理内存池里,通过一个类似操作系统页表的block table来记录映射关系。每个请求有自己的逻辑块序号,table里存的是对应的物理块地址。模型推理时通过查表就能找到KV数据的实际位置。
第三是按需分配和copy-on-write共享。系统只在当前block填满时才分配新的物理块,避免内存浪费。同时支持多个序列共享同一组物理块,比如Parallel Sampling场景下多个采样分支可以复用prompt的KV Cache。只有当某个分支开始写入新数据、路径分叉时,才会通过copy-on-write机制复制到新的物理位置。
实际运行流程是这样,在prefill阶段系统为prompt分配逻辑块并映射到物理块,存入KV数据。在decode阶段每生成一个token就追加写入当前block,满了就分配新块。模型感觉像在连续内存空间工作,但实际物理存储是非连续的。
这个设计带来的好处非常明显。根据vLLM的实验数据,相比HuggingFace Transformers吞吐量可以提升最高24倍,相比TGI也能提升3.5倍。同时天然支持Parallel Sampling、Beam Search这些复杂解码场景,通过统一的映射层就能灵活处理。
简单总结就是,PagedAttention通过把KV Cache分块存储、建立逻辑到物理的映射表、支持按需分配和共享机制,解决了传统系统内存占用高、碎片严重、难以复用的问题,从而大幅提升了大模型推理的吞吐能力。
13. HTTP协议中GET和POST请求的区别?
根据 RFC 规范,GET 的语义是从服务器获取指定的资源,这个资源可以是静态的文本、页面、图片视频等。GET 请求的参数位置一般是写在 URL 中,URL 规定只能支持 ASCII,所以 GET 请求的参数只允许 ASCII 字符 ,而且浏览器会对 URL 的长度有限制(HTTP协议本身对 URL长度并没有做任何规定)。
比如,你打开我的文章,浏览器就会发送 GET 请求给服务器,服务器就会返回文章的所有文字及资源。

根据 RFC 规范,POST 的语义是根据请求负荷(报文body)对指定的资源做出处理,具体的处理方式视资源类型而不同。POST 请求携带数据的位置一般是写在报文 body 中,body 中的数据可以是任意格式的数据,只要客户端与服务端协商好即可,而且浏览器不会对 body 大小做限制。
比如,你在我文章底部,敲入了留言后点击「提交」,浏览器就会执行一次 POST 请求,把你的留言文字放进了报文 body 里,然后拼接好 POST 请求头,通过 TCP 协议发送给服务器。

如果从 RFC 规范定义的语义来看:
- GET 方法就是安全且幂等的,因为它是「只读」操作,无论操作多少次,服务器上的数据都是安全的,且每次的结果都是相同的。所以,可以对 GET 请求的数据做缓存,这个缓存可以做到浏览器本身上(彻底避免浏览器发请求),也可以做到代理上(如nginx),而且在浏览器中 GET 请求可以保存为书签。
- POST 因为是「新增或提交数据」的操作,会修改服务器上的资源,所以是不安全的,且多次提交数据就会创建多个资源,所以不是幂等的。所以,浏览器一般不会缓存 POST 请求,也不能把 POST 请求保存为书签。
但是实际过程中,开发者不一定会按照 RFC 规范定义的语义来实现 GET 和 POST 方法。比如:
- 可以用 GET 方法实现新增或删除数据的请求,这样实现的 GET 方法自然就不是安全和幂等。
- 可以用 POST 方法实现查询数据的请求,这样实现的 POST 方法自然就是安全和幂等。
14. 手撕算法
对于正在准备AI应用开发岗位面试的同学来说,上面这份快手的面经非常有参考价值,特别是其中涉及到的RAG、Agent等技术点,正是当前企业招聘的热门考察方向。技术迭代很快,持续学习和实践是应对面试最好的方法。