LLM API 基础:messages/roles、token、流式、temperature;Agent 到底是什么
从 messages/roles、token、流式和 temperature 出发,搞清楚大模型 API 在做什么,以及 Agent 比聊天多了哪几样东西。
今日目标
- 能用自己的话解释 token、上下文窗口和 messages 数组的关系
- 能写一个流式打印回复的 TypeScript CLI,并切换不同模型
- 能说出 Agent = 模型 + 循环 + 工具 + 记忆,并举一个生产例子
读完今天的内容、做完实验之后,记得回到页面顶部,把这三条目标逐一勾掉——这是你接下来 30 天里第一次真正意义上的"打卡"。
小白版讲解
大模型是一台"超级续写机"
先说一个你每天都在用的东西:手机输入法的联想词。你打下"今天天气",输入法会在上面弹出"真好""不错""怎么样"这几个候选词——它不理解天气,也不关心你今天开不开心,它只是根据海量文本统计出"打完这四个字之后,接下来最可能出现的字是什么"。大语言模型(LLM,Large Language Model)做的其实是同一件事,只不过它看得更远、猜得更准:给它一段文字,它会不断地猜"接下来最可能出现的一个词是什么",把猜出来的词接到后面,再猜下一个,如此循环,直到它觉得该停下来了。你在任何一个聊天类产品里看到的"对话",本质上都是把整段历史拼成一大段文字,交给模型去"续写"——续写出来的那一段,就是我们看到的回复。理解了这一点,后面所有的怪异行为都会变得合理:模型不是在"思考"你的问题,而是在计算"这段文字接下来最合理的延续是什么"。
那模型看到的到底是"字"还是别的东西?答案是 token。token 是模型内部处理文本的最小单位,可能是一个汉字、半个英文单词、一个标点,甚至是一个常见词根。拿"你好,世界"这五个字符举例,它大概会被切成 3 到 4 个 token(具体切法因模型而异);英文通常一个单词约等于 1.3 个 token。这个数字看起来很技术,但它直接决定了两件很现实的事:一是模型一次"能看多少"(下一节会讲),二是你要花多少钱——几乎所有大模型 API 都是按 token 计费的,输入的历史和输出的回复都要算进账单,对话越长,单轮花费就越高。记住这个换算关系,你以后看任何计费文档都不会一脸懵。这一点对 Agent 尤其关键:一个 Agent 会在一个循环里反复调用模型、反复把工具返回的结果也塞进对话历史,token 消耗的增长速度比普通聊天快得多,不懂 token 就很难控制成本。
下一个 token 的候选
(还没开始打分)
和模型对话的 API 长什么样
知道了模型在"续写",接下来看它的接口到底怎么调。抛开所有 SDK 和框架,一次最原始的对话请求就是一个 HTTP POST,长这样:
const res = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'openai/gpt-4o-mini',
messages: [
{ role: 'system', content: '你是一个耐心的编程助教。' },
{ role: 'user', content: '用一句话解释什么是 token' },
],
}),
})
const json = await res.json()
console.log(json.choices[0].message.content)import os
import requests
res = requests.post(
"https://openrouter.ai/api/v1/chat/completions",
headers={
"Authorization": f"Bearer {os.environ['OPENROUTER_API_KEY']}",
"Content-Type": "application/json",
},
json={
"model": "openai/gpt-4o-mini",
"messages": [
{"role": "system", "content": "你是一个耐心的编程助教。"},
{"role": "user", "content": "用一句话解释什么是 token"},
],
},
)
print(res.json()["choices"][0]["message"]["content"])// 依赖:java.net.http(JDK 11+)+ Jackson 做 JSON 解析
var body = """
{
"model": "openai/gpt-4o-mini",
"messages": [
{"role": "system", "content": "你是一个耐心的编程助教。"},
{"role": "user", "content": "用一句话解释什么是 token"}
]
}
""";
var request = HttpRequest.newBuilder(URI.create("https://openrouter.ai/api/v1/chat/completions"))
.header("Authorization", "Bearer " + System.getenv("OPENROUTER_API_KEY"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
var res = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
JsonNode json = new ObjectMapper().readTree(res.body());
System.out.println(json.at("/choices/0/message/content").asText());struct Message: Codable { let role: String; let content: String }
struct ChatRequest: Encodable { let model: String; let messages: [Message] }
struct ChatResponse: Decodable {
struct Choice: Decodable { let message: Message }
let choices: [Choice]
}
var request = URLRequest(url: URL(string: "https://openrouter.ai/api/v1/chat/completions")!)
request.httpMethod = "POST"
let key = ProcessInfo.processInfo.environment["OPENROUTER_API_KEY"]!
request.setValue("Bearer \(key)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode(ChatRequest(
model: "openai/gpt-4o-mini",
messages: [
Message(role: "system", content: "你是一个耐心的编程助教。"),
Message(role: "user", content: "用一句话解释什么是 token"),
]
))
let (data, _) = try await URLSession.shared.data(for: request)
let decoded = try JSONDecoder().decode(ChatResponse.self, from: data)
print(decoded.choices[0].message.content)逐个字段看一下。model 指定用哪个模型;这里用的是 OpenRouter——一个"模型聚合网关",你注册一个 key,就能用同一套接口去调几十家厂商的模型(OpenAI、Anthropic、Google、开源模型等),换模型只需要改这一个字符串,不用重新接一遍 SDK,这也是本课整个实验体系选它做默认入口的原因。messages 是一个数组,代表整段对话历史,每一条都有一个 role:system 用来设定模型的身份、边界和输出风格,通常放在数组最前面;user 是你(或用户)说的话;assistant 是模型之前说过的话。三种角色交替排列,拼起来就是模型要"续写"的那一整段文字。响应回来是一个 JSON,真正的回复文字在 choices 数组第一项的 message.content 里。记住这几个字段的样子——接下来 30 天里你写的每一个 Agent,底层发出的请求基本都是这个形状,只是 messages 数组会随着工具调用的结果不断变长。
上下文窗口:模型的"桌面"有多大
你在办公室有一张桌子,桌面大小是固定的:文件堆多了,旧的就得收进抽屉或者直接扔掉,不然新文件根本摆不下。大模型的"上下文窗口"就是这张桌子——它是一次请求里输入加输出能容纳的 token 总数上限,不同模型这个上限从几万到上百万不等。超过这个上限,请求会直接报错,或者更早之前的内容会被你自己(或框架)截断掉,模型根本"看不到"。
这里有一个第一次接触大模型的人很容易忽略的事实:模型是没有记忆的。每一次 HTTP 请求都是全新的、无状态的,它不会像人一样"记得"你上一句说了什么。所谓"多轮对话",完全是我们在客户端自己维护的一份 messages 数组:每问一句,就把这句用户输入追加进去;模型回复之后,也要把回复追加进去,作为下一次请求的历史一起发过去。如果你忘了把上一轮的回复存回 messages,模型在下一轮里就会"失忆",答非所问。这也解释了为什么长对话会越来越贵:每多聊一轮,历史就多一截,而历史每次都要跟着新问题重新发一遍、重新计费一遍,费用随对话轮数近似线性增长。等到 W1 第 6 天,我们会专门处理这张"桌子"堆满之后怎么办——摘要压缩、滑动窗口这些上下文工程手段;到第 12 天,我们会把真正的长期记忆挪出这张桌子,放进一个可以检索的外部系统里,按需取用而不是每次都全量搬上桌。今天先记住一句话就够:模型没有记忆,历史靠你自己搬。
流式输出:打字机效果不是特效
你可能注意到,几乎所有 AI 聊天产品的回复都是一个字一个字"冒"出来的,很像老电影里的打字机。这不是产品团队特意加的动画效果,而是服务器真实的工作方式:模型本身就是一个词一个词往外吐的(这也回到了第一节的"续写"比喻),如果服务器非要等模型把整段话说完再一次性发给你,用户可能要在空白屏幕前多等十几秒;而如果服务器每算出一个词就立刻推给客户端,用户在一秒之内就能看到第一个字,体验天差地别。这种"边生成边推送"的方式,用的协议通常是 SSE(Server-Sent Events)——一种建立在普通 HTTP 之上的单向文本协议。把请求体里的 stream 设为 true,服务器返回的就不再是一个完整 JSON,而是一连串这样的文本块:
data: {"choices":[{"delta":{"content":"你"}}]}
data: {"choices":[{"delta":{"content":"好"}}]}
data: [DONE]每一块都以 data: 开头,后面跟一段 JSON,里面的 delta.content 就是这一小片新增的文字;全部结束时会收到一行 data: [DONE]。要把这些文本块还原成一个个字符流式打印出来,核心逻辑大概二十行:
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() ?? '' // 最后一段可能是半行,留到下一轮
for (const line of lines) {
if (!line.startsWith('data: ')) continue
const data = line.slice('data: '.length).trim()
if (data === '[DONE]') return
const delta = JSON.parse(data).choices?.[0]?.delta?.content
if (delta) process.stdout.write(delta)
}
}import codecs
import json
# 增量解码器等价于 JS 的 TextDecoder(stream: true):
# 一个汉字被拆到两个网络包里也能正确拼回来
decoder = codecs.getincrementaldecoder("utf-8")()
buffer = ""
for chunk in res.iter_content(chunk_size=None):
buffer += decoder.decode(chunk)
lines = buffer.split("\n")
buffer = lines.pop() # 最后一段可能是半行,留到下一轮
for line in lines:
if not line.startswith("data: "):
continue
data = line[len("data: "):].strip()
if data == "[DONE]":
return
delta = json.loads(data)["choices"][0]["delta"].get("content")
if delta:
print(delta, end="", flush=True)// InputStreamReader 内部就会处理跨包的半个 UTF-8 字符
var reader = new InputStreamReader(res.body(), StandardCharsets.UTF_8);
var chunk = new char[4096];
var buffer = new StringBuilder();
int n;
while ((n = reader.read(chunk, 0, chunk.length)) != -1) {
buffer.append(chunk, 0, n);
int nl;
while ((nl = buffer.indexOf("\n")) >= 0) {
var line = buffer.substring(0, nl);
buffer.delete(0, nl + 1); // 剩下的半行留在 buffer 里,等下一轮
if (!line.startsWith("data: ")) continue;
var data = line.substring("data: ".length()).trim();
if (data.equals("[DONE]")) return;
var delta = mapper.readTree(data).at("/choices/0/delta/content");
if (!delta.isMissingNode()) System.out.print(delta.asText());
}
}struct StreamChunk: Decodable {
struct Choice: Decodable {
struct Delta: Decodable { let content: String? }
let delta: Delta
}
let choices: [Choice]
}
// URLSession 的 .lines 已经替你做了分包缓冲与半行拼接,
// 相当于 JavaScript 版里那段手写 buffer 的内置实现
let (bytes, _) = try await URLSession.shared.bytes(for: request)
for try await line in bytes.lines {
guard line.hasPrefix("data: ") else { continue }
let data = line.dropFirst("data: ".count).trimmingCharacters(in: .whitespaces)
if data == "[DONE]" { break }
guard let raw = data.data(using: .utf8),
let chunk = try? JSONDecoder().decode(StreamChunk.self, from: raw),
let delta = chunk.choices.first?.delta.content else { continue }
print(delta, terminator: "")
}用 getReader 一块一块读原始字节,用 TextDecoder 转成文字,再按换行符切成一行一行处理——这跟你平时处理接口响应的方式很不一样,因为网络传来的数据不会乖乖按"一行一个事件"的节奏打包好交给你。这套"逐块读取、按行解析"的模式不只用来打字机式打印文字:Agent 在执行过程中把中间状态(正在想什么、正在调哪个工具)实时推给前端,用的也是同一套思路,让用户看到"它正在做什么",而不是对着一个转圈的黑箱干等。
服务器想发的(完整报文)
data: {"choices":[{"delta":{"content":"你"}}]}
data: {"choices":[{"delta":{"content":"好"}}]}
data: [DONE]
实际到达的网络包
(还没开始收)
buffer 里留着的半行
(空)
已解析出的完整事件
断线重连:三种"断"其实是三个问题
流式讲完了,紧接着的问题一定是:网络断了怎么办?这个问题看着只有一个,实际上是三个,答案互不通用——面试里把它们混为一谈是很常见的失分点。
第一种,浏览器原生的 EventSource。 它是规范定义的 SSE 客户端,断线自动重连:服务器用 id: 给事件打号,浏览器记住最后收到的号,重连时放进 Last-Event-ID 请求头发回去,服务器就知道从哪儿接着发;retry: 还能指定重连间隔。这一整套都不用你写代码。但它有两个硬限制:只能发 GET 请求,而且响应的 Content-Type 必须是 text/event-stream——状态码不是 200 或者类型不对,连接直接判定失败,不重连;反过来,服务器想主动叫停重连,标准做法是返回一个 204。
第二种,也就是你实际会遇到的那种。 大模型接口必须 POST(messages 要放在请求体里),而 EventSource 只能发 GET。所以真实代码都是 fetch 加手写解析,上面那段就是。这意味着第一种的自动重连一行都用不上,断了就是断了,重试逻辑得你自己写。恢复的思路是"续写"而不是重来:把已经收到的半截内容作为上下文发起新请求,让模型接着往下写,省下重新生成的时间和钱。但这里有个边界要记住:工具调用和思考过程这类结构化内容没法只恢复一半,只能从最近一个完整的文本块续。
第三种,用户干脆把网页关了。 这时前端写多少重试都没用,因为前端已经不存在了。唯一的出路是让生成过程脱离这个客户端独立活着:服务端一边把 token 推给当前连接,一边把同样的内容写进 Redis 之类的地方,并在会话记录里存下这条流的 id;用户回来时,前端拿会话 id 去请求一个专门的恢复接口,服务端按 id 找到那条流接着推,找不到就回 204 表示没有需要恢复的东西。代价是多一份存储、一套过期清理,以及同一条流被多个连接同时消费时的并发处理。这套东西正好是 W2 里程碑项目 mini-koda 要搭的消息队列与状态机,到时候你会亲手写一遍。
temperature 与采样:什么时候 0,什么时候 0.7
还记得续写机的比喻吗?模型每一步其实是在给"接下来可能的词"打分,算出一份概率分布,再从里面挑一个。temperature 控制的就是"挑"这个动作有多随性——你可以把它想象成掷骰子时的"作弊程度":temperature 设成 0,相当于每次都直接选分最高的那个词,完全不掷骰子,同样的输入几乎总能得到同样的输出;temperature 调高,模型会更愿意"赌一把",偶尔选中排名靠后但依然合理的词,输出变得更有变化、也更不可预测。top_p 是另一种控制随机性的旋钮,思路不同:它不是缩放概率分布,而是只从"累计概率达到 p"的那一小圈候选词里挑,相当于提前把明显不靠谱的选项砍掉再抽签。两者经常一起出现在文档里,但实践中一般只调其中一个,同时调容易互相干扰、难以判断到底是谁在起作用。
选哪个值,取决于你要模型干什么活:
| 场景 | 推荐值 |
|---|---|
| 结构化输出、工具调用参数、分类判断 | 0(或接近 0) |
| 日常问答、写文档、写代码 | 0.2–0.5 |
| 创意写作、头脑风暴、闲聊 | 0.7–1.0 |
对 Agent 来说这张表格特别现实:Agent 里很多步骤(判断该不该调用工具、往工具里填什么参数)本质上是"分类"或"抽取",需要的是稳定、可复现、能重复测试的结果,所以这些环节几乎都会把 temperature 设成 0;只有在生成最终给用户看的自然语言回复时,才会考虑调高一点让语气不那么死板。
从聊天到 Agent:差在哪
到这里,我们已经把"跟大模型对话"这件事拆解完了:拼一个 messages 数组发过去,模型续写出一段回复,用流式协议把它逐字推回来,用 temperature 控制这次续写有多"听话"。这就是一个聊天机器人的全部——一问一答,问完就结束,模型对外部世界没有任何影响力。
那 Agent 比这多了什么?一句话公式:Agent = 模型 + 循环 + 工具 + 记忆。"循环"是说 Agent 不再是问一句答一句就收工,而是自己反复走一个小闭环——先想一想该干什么(思考),再去执行一个动作(调工具),再看看执行的结果怎么样(观察),带着这个结果继续想下一步该干什么,直到目标达成才停下来。"工具"是说这个循环里模型不再只是吐文字,它可以"请求"去执行一个真实的动作——查数据库、发一条消息、算一道题——从而对外部世界产生实际影响,而不只是生成一段可以被人读到的文本。"记忆"是说这个循环不只发生在一轮对话里,跨越多轮、甚至跨越多次会话,Agent 都要能记得关键信息,而不是每次从零开始。这三样东西,恰好都不是今天讲的"聊天 API"自带的,需要工程师自己在模型外面搭建起来——这也是接下来这几周课程要一步步动手补上的部分:明天我们就会不借助任何框架,手写这个"思考 → 调工具 → 看结果"的循环,亲眼看看它到底是几行代码。
值得提前打个预防针:一旦这样的 Agent 要在生产环境里服务成千上万个用户,光有"一个循环"远远不够。真实系统往往会把"接入用户请求"和"真正执行 Agent 循环"分成两层,中间用一条消息队列连接,避免某个用户的慢任务拖累其他人;每次任务的进度会记录成一个状态机(排队、执行中、等待工具结果、完成、失败),方便追踪和恢复;每次模型调用花了多少 token、多少钱,也需要专门的成本计量模块持续统计,否则容易在不知不觉中烧穿预算。"接入层与执行层分离、消息队列、状态机、成本计量"这类基础设施,正是 W2 里程碑项目 mini-koda 里你要亲手搭建一遍的东西——今天先知道它们存在、知道为什么需要就够了。
messages 数组(每轮都要整个重发一遍)
源码导读
动手实验
动手写之前,先用下面这个面板对着你自己的 key 试一次:填上地址和密钥点"测试连接",确认通了再去写代码,能省掉一大半"到底是我代码错了还是 key 不对"的排查时间。密钥只留在你自己的浏览器里,本站服务器不接收也不记录。生成到一半时点一下"模拟断网",还能亲眼看到上一节讲的那种半截回复。
大模型连接实验区
填上你自己的接口地址和密钥,直接从这个浏览器发请求验证是否打通。密钥只留在你的浏览器里,本站服务器不接收、不记录、不转发——这个页面没有任何服务端接口参与,请求是浏览器直连目标服务的。
跟着下面五步走,对照 labs/agent-30days/day-01-streaming-cli 里的 starter/ 动手写,卡住了再看 solution/:
- 先跑一次性请求:不开
stream,直接fetch拿完整 JSON 响应,打印出choices[0].message.content,确认 key 和网络都通了。 - 把请求体的
stream改成true,用getReader逐块读取响应,按行解析以data:开头的 SSE 事件,把每个delta打印出来,看到打字机效果出现。 - 用
node:readline/promises包一层多轮循环:每一轮把用户的输入和模型的回复都存进messages数组,验证"模型记得上一轮说了什么"。 - 加一个
MODEL环境变量,换成另一个模型 id 跑一遍,对比两次回复在风格和速度上的差异。 - 把响应里的
usage字段打印出来,看看这一次完整对话到底花了多少 token。
面试题
今天 10 道题在下方题库区,后 5 道专攻断线与重连。展开后先看"分析过程"再看要点——照着推导练,比背要点管用。标注"国内高频 / 海外高频"方便按目标市场取舍。
检查清单与明日预告
- 能用自己的话解释 token、上下文窗口和 messages 数组的关系
- 能写一个流式打印回复的 TypeScript CLI,并切换不同模型
- 能说出 Agent = 模型 + 循环 + 工具 + 记忆,并举一个生产例子
- 能说清断线重连的三层分别是什么,以及为什么第一层的自动重连在 POST 流上用不了
- 实验的 4 条验收标准全部通过
- 10 道面试题不看要点也能答出至少 6 道
明天(D2)我们会完全不借助任何框架,手写一个能调用工具的 Agent 循环,用代码亲眼验证今天说的"循环 + 工具"到底是什么样子。先手写再学框架是故意安排的顺序:框架帮你把这个循环封装成了几行 API 调用,但如果你没有亲手实现过一次,遇到框架报错或者行为跟预期不一致时就很难判断问题出在哪一层——先搞懂骨架,再享受框架带来的便利,才不会变成只会调 API 的"配置工程师"。
面试题库
什么是 token 和上下文窗口?它们如何影响 Agent 的设计?What are tokens and the context window, and how do they shape agent design?
国内高频海外高频基础#llm-basics#context分析过程 · 先想清楚再作答
- 先判断这题问的是「概念」还是「工程后果」。只答定义会被认为没做过工程,必须落到设计影响上。
- 从一条因果链推:token 是计费与长度的计量单位 → 窗口是这个单位的上限 → 模型无状态、历史每轮重发 → 成本随轮数增长 → 所以必须做上下文工程。
- 关键要点出在「Agent 比聊天更严重」:Agent 在循环里反复调模型,还要把工具返回结果也塞回历史,增长速度快得多。
- 结论给出具体手段:滑动窗口、摘要压缩、长期记忆外置到检索系统,并说明各自代价。
- 可以预期的追问:窗口没满为什么也要压缩?答案是长上下文会稀释注意力、抬高延迟与成本,不是塞满了才处理。
How to reason about it · think before answering
- First decide whether this asks for definitions or engineering consequences; a definition-only answer reads as inexperienced.
- Follow the causal chain: tokens are the unit of billing and length, the window caps that unit, models are stateless so history is resent every turn, cost grows with turns, hence context engineering.
- The differentiator is why agents suffer more: a loop calls the model repeatedly and appends tool results back into history.
- Close with concrete tactics: sliding window, summarization, externalized long-term memory, and the cost of each.
- Expect the follow-up: why compress before the window is full? Long contexts dilute attention and raise latency and cost.
答题要点
- token 是模型处理文本的最小单位,大致 1 个汉字 ≈ 1–2 token,1 个英文单词 ≈ 1.3 token
- 上下文窗口是一次请求里输入 + 输出 token 的上限;超出就要截断或压缩
- 模型没有记忆,历史必须每轮重新塞进 messages,所以长对话的成本随轮数线性增长
- Agent 设计因此要做上下文工程:滑动窗口、摘要压缩、把长期记忆外置到检索系统
Key points
- A token is the smallest unit the model processes; roughly 1.3 tokens per English word
- The context window caps input + output tokens per request; beyond it you truncate or compress
- Models are stateless, so the full history is re-sent every turn and cost grows with length
- Hence context engineering: sliding windows, summarization, and external long-term memory
messages 里的 system / user / assistant 三种角色各起什么作用?为什么要有 system?What do the system / user / assistant roles do, and why does system exist?
国内高频海外高频基础#llm-basics#prompt分析过程 · 先想清楚再作答
- 题眼在后半句「为什么要有 system」——前半句是送分,后半句才是区分度所在。
- 先说清三者构成一段可被模型续写的完整文本,角色是给这段文本打的结构化标记。
- 再回答「为什么」:如果把规则写进 user,它就只是对话里的一句话,会被后续几十轮对话稀释;放进 system 才能保持稳定权重,且便于产品侧统一管控、单独灰度。
- 补一条生产视角:真实的 system prompt 通常是模板拼出来的——人设 + 工具说明 + 记忆片段 + 当前时间,而不是一个写死的字符串。
- 常见追问:能不能把 system 放在最后?可以但不推荐,多数模型对靠前的指令更敏感,且会破坏缓存前缀。
How to reason about it · think before answering
- The discriminating half is 'why does system exist'; the first half is a warm-up.
- Explain that the three roles are structural markers over one continuous text the model continues.
- Then the why: rules placed in user are just another turn and get diluted over dozens of turns; system keeps stable weight and can be governed centrally.
- Add production nuance: a real system prompt is templated — persona plus tool docs plus memory plus runtime facts.
- Likely follow-up: can system go last? Possible but unwise — models weight earlier instructions more and it breaks prompt-cache prefixes.
答题要点
- system 设定身份、边界与输出格式,通常放在最前面,权重高于普通对话
- user 是用户输入,assistant 是模型历史回复,两者交替构成对话记录
- 把规则放 system 而不是 user,是为了让规则不被后续对话冲淡,也便于产品统一管控
- 生产里 system prompt 往往由模板拼接:人设 + 工具说明 + 记忆 + 当前时间等动态信息
Key points
- system sets identity, constraints and output format; it sits first and carries more weight
- user is the human turn, assistant is the model's prior replies; they alternate
- Rules live in system so they are not diluted by later turns and can be controlled centrally
- In production the system prompt is templated: persona + tool docs + memory + runtime facts
为什么 LLM 应用几乎都用流式输出?SSE 和 WebSocket 该怎么选?Why do LLM apps stream responses, and how do you choose between SSE and WebSockets?
国内高频海外高频进阶#streaming#protocol分析过程 · 先想清楚再作答
- 第一问考的是对延迟指标的敏感度:要能区分「首字延迟」和「全文延迟」,并说出模型逐 token 生成决定了前者远小于后者。
- 把它翻译成产品语言:用户 1 秒内看到反馈 vs 对着空白等 20 秒,这是体验的分水岭,不是锦上添花。
- 第二问不要背优缺点表,先问自己「客户端需不需要频繁上行」——这一条几乎决定了答案。
- 只需要服务器往下推 token,SSE 就够:它跑在普通 HTTP 上,代理和负载均衡友好,还自带重连。需要语音、协同、频繁打断这类双向高频交互,才值得上 WebSocket。
- 给出多数产品的真实形态:请求走普通 POST,回复走 SSE,另配一个取消接口——顺势可以引到「POST 的 SSE 用不了 EventSource 的自动重连」这个坑。
How to reason about it · think before answering
- The first half tests latency literacy: separate time-to-first-token from total latency and tie it to sequential generation.
- Translate to product terms: feedback within a second versus twenty seconds of blank screen.
- For the second half, skip the pros-and-cons table and ask whether the client needs frequent upstream messages.
- Server-to-client tokens only means SSE suffices: plain HTTP, proxy-friendly, with built-in reconnection. Voice, collaboration or frequent interrupts justify WebSockets.
- State the common shape: plain POST for the request, SSE for the reply, plus a cancel endpoint — which sets up the trap that POST-based SSE cannot use EventSource auto-reconnect.
答题要点
- 模型逐 token 生成,首字延迟远小于全文延迟;流式让用户 1 秒内看到反馈而不是等 20 秒
- SSE 是单向、基于 HTTP 的文本协议,自动重连、穿透代理容易,天然适合服务器→客户端的 token 流
- WebSocket 双向、更适合需要客户端频繁上行(语音、协同编辑、打断)的场景,但代理/负载均衡更麻烦
- 多数聊天产品:请求用普通 HTTP POST,回复用 SSE;需要打断时再加一个取消接口
Key points
- Models emit tokens sequentially; time-to-first-token is far lower than full latency
- SSE is one-way over HTTP with built-in reconnect and easy proxying, ideal for server→client token streams
- WebSockets are bidirectional, better when the client sends often (voice, collaboration, interrupts) but harder to load-balance
- Most chat products: plain POST for the request, SSE for the reply, plus a cancel endpoint
聊天机器人和 Agent 的本质区别是什么?What fundamentally separates a chatbot from an agent?
国内高频海外高频基础#agent-basics分析过程 · 先想清楚再作答
- 这题最容易答成营销话术。判断标准很简单:你的回答里有没有出现「工程代价」,没有就是背概念。
- 先给结构:聊天是一问一答的单次调用;Agent 是在循环里反复「思考 → 调工具 → 观察」直到目标达成。
- 点出三个新增件——循环、工具、记忆——并强调关键差异是「工具能对外部世界产生副作用」,这是可逆与不可逆的分界线。
- 紧接着说代价:有副作用就要管权限与沙箱,有循环就要管步数与成本预算,有多步就要可观测性和失败重试。这一段才是面试官想听的。
- 用一个具体例子收尾(能查库、发消息、定时提醒的助手),并点出它背后需要队列、状态机、成本计量。
How to reason about it · think before answering
- This one invites marketing language; the test is whether your answer names engineering costs.
- Give the structure first: a chatbot is one call, an agent loops think → act → observe until the goal is met.
- Name the three additions — loop, tools, memory — and stress that tools cause side effects on the world.
- Immediately pair each with its cost: permissions and sandboxing, step and budget caps, observability and retries.
- Close with a concrete example and the infrastructure it implies: queues, state machines, cost metering.
答题要点
- 聊天机器人是一问一答;Agent 是模型在一个循环里反复思考、调用工具、观察结果直到完成目标
- 三个新增件:循环(多步)、工具(能对外界产生副作用)、记忆(跨轮次/跨会话)
- 随之而来的工程问题:工具权限与沙箱、失败重试、成本与步数预算、可观测性
- 举例:一个能查库、发消息、定时提醒的助手,背后要有消息队列、状态机和成本计量
Key points
- A chatbot answers once; an agent loops think → act (tool call) → observe until the goal is met
- Three additions: a loop (multi-step), tools (side effects on the world), memory (across turns/sessions)
- They bring engineering concerns: tool permissions and sandboxing, retries, step/cost budgets, observability
- Example: an assistant that queries a DB, sends messages and schedules reminders needs queues, state machines and cost tracking
temperature 和 top_p 分别控制什么?什么场景用 0,什么场景用 0.7?What do temperature and top_p control, and when would you use 0 versus 0.7?
海外高频基础#llm-basics#sampling分析过程 · 先想清楚再作答
- 先说清两者作用在同一个地方——模型算出的下一个 token 概率分布——但作用方式不同,这是区分度所在。
- temperature 是缩放整个分布:越低越尖锐、越确定;top_p 是截断——只保留累计概率达到 p 的那一小圈候选再采样。
- 由此推出实践建议:一般只调其中一个,两个同时调会互相干扰,出了问题分不清是谁造成的。
- 选值不按「创意程度」凭感觉,按「这一步的输出要不要可复现」来定:工具参数、分类判断、结构化输出必须可复现,用 0。
- 补一句 Agent 视角:Agent 的规划与工具调用环节几乎都用低温,只有最终面向用户的自然语言回复才考虑调高。
How to reason about it · think before answering
- Establish that both act on the same next-token distribution but in different ways — that is the discriminator.
- temperature rescales the whole distribution; top_p truncates it to the smallest set reaching cumulative probability p.
- Hence the practical rule: tune one, not both, or you cannot attribute a regression.
- Choose by reproducibility, not by vibes: tool arguments, classification and structured output must be reproducible, so use 0.
- Add the agent angle: planning and tool-calling steps stay cold; only the final user-facing prose warrants higher values.
答题要点
- temperature 缩放下一个 token 的概率分布:越低越确定,越高越随机
- top_p 只从累计概率达到 p 的候选里采样,是另一种截断随机性的方式;一般只调其中一个
- 结构化输出、工具参数、分类判断用 0 或接近 0,保证可复现
- 创意写作、头脑风暴用 0.7–1.0;生产 Agent 的规划步骤通常也偏低温
Key points
- temperature rescales the next-token distribution: lower is more deterministic, higher more random
- top_p samples only from the smallest set whose cumulative probability reaches p; tune one, not both
- Use ~0 for structured output, tool arguments and classification to keep results reproducible
- Use 0.7–1.0 for creative writing; planning steps in production agents usually stay low
流式回复到一半网络断了,前端和后端各要做什么?EventSource 的自动重连能用上吗?A streaming reply is cut off mid-way. What do the client and server each do, and can EventSource auto-reconnect help?
国内高频海外高频进阶#streaming#reliability#sse分析过程 · 先想清楚再作答
- 这题的陷阱在后半句。很多人背过「SSE 自带重连」,就直接答自动重连能救——那是错的,必须先分清两种 SSE 用法。
- 浏览器原生 EventSource 确实按规范自动重连:重连时带 Last-Event-ID 请求头,服务器用 id: 打点、用 retry: 设间隔;但它只能发 GET,且要求响应 Content-Type 是 text/event-stream。
- 而 LLM chat API 必须 POST(messages 要放在请求体里),所以实际用的是 fetch 加手写 SSE 解析——EventSource 那套自动重连一行都用不上。
- 于是前端职责变成:自己判定断流、自己重试、自己保存已收到的部分。后端职责是让重试是安全的——响应可续、副作用幂等。
- 给出续写策略并说清边界:把已收到的内容作为上下文构造续写请求;但工具调用块和思考块无法部分恢复,只能从最近的完整文本块续。
- 可预期追问:非 200 响应会重连吗?按规范不会——状态码不是 200 或 Content-Type 不对,连接直接判定失败;服务器还可以用 204 主动叫停重连。
How to reason about it · think before answering
- The trap is the second half: people who memorized 'SSE reconnects automatically' answer yes, which is wrong.
- Native EventSource does auto-reconnect per spec, sending Last-Event-ID, with the server marking events via id: and setting the interval via retry: — but it only issues GET and requires Content-Type text/event-stream.
- LLM chat APIs require POST because messages go in the body, so real clients use fetch plus hand-written SSE parsing, where none of that machinery applies.
- So the client owns detection, retry and buffering of what arrived; the server's job is making retries safe — resumable output and idempotent side effects.
- Give the continuation strategy and its limits: feed the received prefix back as context, but tool-use and thinking blocks cannot be partially recovered — resume from the last complete text block.
- Follow-up to expect: does a non-200 reconnect? Per spec no — a non-200 status or wrong Content-Type fails the connection, and a 204 tells the browser to stop reconnecting.
答题要点
- 先区分两种 SSE:浏览器原生 EventSource 自动重连并带 Last-Event-ID,但只能 GET;LLM API 走 POST,用不上这套
- 所以前端要自己检测断流、自己重试,并保留已收到的部分内容
- 续写策略:把已收到的内容作为上下文发起新请求,让模型接着写,而不是整轮重来
- 边界:tool_use 和 thinking 块无法部分恢复,只能从最近的完整文本块续
- 后端要保证重试安全:响应可续、工具副作用幂等,并对已产生的用量正确计费
Key points
- Separate the two SSE modes: native EventSource auto-reconnects with Last-Event-ID but is GET-only; LLM APIs use POST and cannot rely on it
- The client must therefore detect the break, retry itself, and keep whatever text already arrived
- Continuation: send the received prefix as context so the model resumes rather than restarting the turn
- Limits: tool_use and thinking blocks cannot be partially recovered; resume from the last complete text block
- The server must make retries safe: resumable responses, idempotent tool side effects, correct billing for tokens already produced
用户切到后台或者直接关掉网页,回来后怎么恢复那条还在生成的回复?The user backgrounds the app or closes the tab. How do you restore a reply that was still being generated?
国内高频海外高频深入#streaming#reliability#architecture分析过程 · 先想清楚再作答
- 先识别这题和「网络断了」不是同一个问题:客户端已经不存在了,任何写在前端的重试逻辑都不会执行。
- 由此推出唯一出路:生成过程必须能脱离这个客户端独立存活,也就是把流本身放到服务端持久化。
- 落到具体架构:发起请求时给这轮生成分配一个流 id,服务端一边把 token 推给当前连接,一边把同样的内容写进 Redis 之类的存储;会话记录里保存这个 activeStreamId。
- 恢复路径是另开一个 GET 端点:客户端带着会话 id 请求,服务端按 activeStreamId 找到那条流并接着推;找不到活跃流就返回 204,让前端知道没有需要恢复的东西。
- 说清代价,别只说方案:多了一份存储、一套过期清理、以及「同一条流可能被多个连接消费」的并发问题。
- 延伸:这套结构和普通聊天产品的「消息已持久化,重进会话直接读库」不同——区别在于回复还在生成中,需要的是可续的流而不是一条静态记录。
How to reason about it · think before answering
- First separate this from a dropped connection: the client is gone, so no client-side retry will ever run.
- That leaves one option — the generation must outlive the client, which means persisting the stream server-side.
- Concretely: assign a stream id per generation; the server pushes tokens to the live connection while also writing them to storage such as Redis, and the chat record stores that activeStreamId.
- Recovery is a separate GET endpoint: the client asks with the chat id, the server locates the stream by activeStreamId and resumes; with no active stream it returns 204.
- Name the costs, not just the design: extra storage, expiry/cleanup, and concurrency when several connections consume the same stream.
- Extension: this differs from ordinary message persistence because the reply is still being produced — you need a resumable stream, not a static row.
答题要点
- 客户端已经不在了,前端重试无从谈起,必须让生成过程在服务端独立存活
- 发起生成时分配流 id,服务端边推送边把内容写进 Redis,会话里记录 activeStreamId
- 恢复走单独的 GET 端点:按会话 id 找到活跃流接着推,没有活跃流就返回 204
- 代价:额外存储、过期清理,以及同一条流被多个连接消费的并发处理
- 与「消息持久化后重新读库」的区别在于回复仍在生成中,需要的是可续的流
Key points
- The client is gone, so recovery must live server-side: the generation has to outlive the connection
- Assign a stream id at start; the server writes tokens to Redis while streaming, and the chat stores activeStreamId
- Resume through a dedicated GET endpoint that replays the active stream, returning 204 when there is none
- Costs: extra storage, expiry and cleanup, and concurrent consumers of one stream
- It differs from plain message persistence because the reply is still in flight, so you need a resumable stream
断线重试之后,怎么保证不重复计费、也不重复执行已经做过的工具调用?After a retry, how do you avoid double billing and re-executing tool calls that already ran?
国内高频海外高频深入#reliability#tools#idempotency分析过程 · 先想清楚再作答
- 先把问题拆成两半:计费是「记录问题」,工具副作用是「执行问题」,两者的解法不同,混在一起答会含糊。
- 计费侧:用量应该在服务端按实际收到的 token 记账,而不是按「请求次数」。断在中途已经产生的 token 是真实成本,要照记;重试产生的是新成本,也要照记——关键是别把同一批 token 记两遍。
- 为此需要一个稳定的标识:给每轮生成一个 run id,用量记录以 run id + 序号去重,重放同一段不会重复入账。
- 工具侧:真正危险的是有副作用的工具(转账、发消息、下单)。解法是幂等键——由调用参数派生一个稳定的 key,执行前先查这个 key 是否已有结果,有就直接返回旧结果。
- 补一层状态机视角:把每次工具调用记为「待执行 / 执行中 / 已完成」,重试时只重放未完成的部分,已完成的直接取结果,这也是恢复中断任务的通用做法。
- 常见追问:幂等键该谁生成?应由客户端或调度侧生成并随请求传递,服务端自己生成就没法跨重试保持一致。
How to reason about it · think before answering
- Split it in two: billing is a bookkeeping problem, tool side effects are an execution problem, and they have different fixes.
- Billing: meter server-side by tokens actually produced, not by request count. Tokens produced before the break are real cost; so are retry tokens. The point is not to count the same batch twice.
- That needs a stable identifier: give each generation a run id and dedupe usage records by run id plus sequence.
- Tools: the danger is side-effecting tools — transfers, messages, orders. The fix is an idempotency key derived from the call arguments, checked before execution.
- Add the state-machine view: record each call as pending / running / done and replay only what is unfinished.
- Follow-up: who generates the idempotency key? The caller must, and pass it along — a server-generated key cannot stay stable across retries.
答题要点
- 拆成两个问题:计费是记账问题,工具副作用是执行问题,解法不同
- 计费按服务端实际产生的 token 记,用 run id 加序号去重,避免同一批 token 重复入账
- 有副作用的工具用幂等键:由调用参数派生稳定 key,执行前先查是否已有结果
- 把每次工具调用记成待执行/执行中/已完成的状态机,重试只重放未完成的部分
- 幂等键要由调用方生成并随请求传递,服务端自行生成无法跨重试保持一致
Key points
- Separate billing (bookkeeping) from tool side effects (execution); they need different mechanisms
- Meter by tokens actually produced, deduped by run id plus sequence so one batch is never counted twice
- Guard side-effecting tools with an idempotency key derived from the call arguments
- Model each tool call as pending / running / done and replay only unfinished work
- The caller must generate and pass the idempotency key so it stays stable across retries
移动端 App 里的对话,网络频繁抖动,你会怎么设计重连策略?On mobile, connectivity is flaky. How would you design the reconnection strategy for a chat feature?
国内高频海外高频进阶#reliability#mobile#streaming分析过程 · 先想清楚再作答
- 先说明移动端和浏览器的差别:网络在 WiFi 与蜂窝之间切换、App 会被系统挂起、后台执行时间受限,所以不能照搬网页那套。
- 重试节奏用指数退避加随机抖动。抖动这一条常被忽略,但它是防止大面积断网恢复后所有客户端同时涌上来把服务打垮的关键。
- 要设上限:最大重试次数与最大退避间隔,超过就转成显式的「重新加载」按钮交给用户,而不是无限静默重试。
- 区分「短暂抖动」和「真的没网」:监听系统的网络状态变化,没网时直接停止重试并进入离线态,等网络恢复事件再立刻重连,比盲目定时重试省电得多。
- 结合上一题的服务端持久化:App 被系统杀掉后重进,靠会话 id 请求恢复端点,而不是指望本地缓存拼出完整回复。
- 最后补发送侧:用户在离线时发出的消息进本地队列,恢复后按序重发,且每条带幂等键,避免重复发送。
How to reason about it · think before answering
- Start with what makes mobile different: network switches between WiFi and cellular, the OS suspends apps, background time is limited.
- Use exponential backoff with jitter; jitter is the commonly missed part that prevents a thundering herd when a wide outage clears.
- Set ceilings: max attempts and max interval, then surface an explicit reload action instead of retrying silently forever.
- Distinguish a brief blip from being genuinely offline: subscribe to OS connectivity events, stop retrying when offline, and reconnect on the restore event — far cheaper on battery than blind timers.
- Combine with server-side persistence: after the OS kills the app, resume by chat id rather than reconstructing from local cache.
- Finally the send path: queue outgoing messages while offline and replay them in order, each with an idempotency key.
答题要点
- 移动端特殊性:WiFi 与蜂窝切换、App 被挂起、后台执行时间受限,不能照搬网页策略
- 指数退避加随机抖动,抖动用于避免大面积恢复时的重连风暴
- 设最大重试次数与最大间隔,超过后转为显式的重新加载入口,不做无限静默重试
- 监听系统网络状态:离线直接停重试进入离线态,收到恢复事件再重连,比定时轮询省电
- 回复恢复依赖服务端持久化,靠会话 id 请求恢复端点;发送侧用本地队列加幂等键按序重发
Key points
- Mobile differs: network handoffs, OS suspension, limited background time — do not copy the web strategy
- Exponential backoff with jitter, where jitter prevents a reconnect storm when an outage clears
- Cap attempts and interval, then hand the user an explicit reload instead of retrying forever
- Listen to OS connectivity events: stop while offline, reconnect on restore, which saves battery over polling
- Resume replies via server-side persistence by chat id; queue outgoing messages with idempotency keys
用户主动点「停止生成」和网络意外断开,在服务端看起来都是连接没了,怎么区分处理?A user pressing stop and a dropped connection both look like a closed connection server-side. How do you tell them apart?
国内高频海外高频深入#streaming#reliability#ux分析过程 · 先想清楚再作答
- 先点破为什么要区分:主动停止是「用户不想要了」,应当立即释放算力并结束这轮;意外断开是「用户还想要」,理应保留结果供恢复。处理反了,用户要么白花钱,要么回来发现内容没了。
- 所以不能只靠 TCP 连接状态判断——它对两种情况的表现是一样的。必须有一个显式信号。
- 做法是给「停止」单独一个接口:前端点停止时先调这个接口,带上 run id,服务端据此把该轮标记为「用户取消」,再中止上游模型调用。
- 而单纯的连接关闭一律按「意外断开」处理:继续把已生成内容落盘、保留可恢复的流,等客户端回来续。
- 补一个现实约束:停止请求本身也可能因为断网而发不出去。所以服务端还需要兜底——比如流没有任何消费者超过一定时间就自行结束,避免算力空转。
- 延伸到计费:两种情况都要为已经产生的 token 计费,因为上游厂商已经收了钱;区别只在于要不要保留结果和是否继续生成。
How to reason about it · think before answering
- Say why it matters: stop means the user no longer wants the output, so free compute and end the run; a drop means they still want it, so preserve the result for resumption.
- Connection state alone cannot distinguish them — it looks identical — so you need an explicit signal.
- Give stop its own endpoint: the client calls it with the run id before closing, and the server marks the run as user-cancelled and aborts the upstream call.
- Treat a bare connection close as an unexpected drop: keep persisting output and hold the stream for resumption.
- Add the real-world caveat: the stop request itself may fail to send when the network is down, so the server needs a fallback — end a stream with no consumer after a timeout.
- Extend to billing: both cases still owe for tokens already produced, since the upstream provider has charged; they differ only in whether output is retained.
答题要点
- 两者语义相反:主动停止要立即释放算力并结束,意外断开要保留结果等待恢复
- TCP 连接状态无法区分,必须有显式信号:给停止单独一个接口,带 run id 标记为用户取消
- 只收到连接关闭一律按意外断开处理,继续落盘并保留可恢复的流
- 兜底:停止请求本身也可能发不出去,服务端需对长时间无消费者的流自行结束
- 计费上两者都要为已产生的 token 记账,区别只在于是否保留结果、是否继续生成
Key points
- The semantics are opposite: stop frees compute immediately, a drop preserves output for resumption
- Connection state cannot distinguish them, so add an explicit stop endpoint carrying the run id
- Treat a bare close as an unexpected drop: keep persisting and hold the stream for resume
- Fallback: the stop call may itself fail to send, so end streams with no consumer after a timeout
- Both still bill for tokens already produced; they differ only in retention and whether generation continues
评论
登录后即可参与讨论
还没有评论,来说第一句。