1. 重点
- 第 0 课真正要看懂的是这条链路:
- 用户敲
hx ask "你是谁"。 - Node.js 收到命令参数。
- 代码把参数变成
messages。 fetch()发一个 HTTP POST 请求给 DeepSeek。- DeepSeek 返回 JSON。
- 代码从 JSON 里取出 assistant 的文本。
- 用户敲
- Agent / Harness 往后会变复杂,但
第一性原理很简单:- 本质就是一个程序。
- 程序把上下文组织成 HTTP 请求。
- 大模型根据请求返回下一句话。
- 程序再决定把这句话显示出来,还是继续做别的事。
2. 流程图
2.1. HTTP 一来一回
2.2. 多轮对话的一来一回
3. 入口
先看命令怎么连到文件。
{
"bin": {
"hx": "./src/index.js"
}
}- 这段配置只解决一件事:
- 用户敲
hx。 - npm 找到
src/index.js。 - Node.js 执行这个文件。
- 用户敲
文件第一行写:
`#!/usr/bin/env` node
// 这行告诉系统:
// 当前文件要交给 node 执行。真正的参数入口是:
runCli(process.argv.slice(2));
// 用户输入:
// hx ask "你是谁"
//
// process.argv 大概是:
// [
// "/path/to/node",
// "/path/to/hx",
// "ask",
// "你是谁"
// ]
//
// slice(2) 之后,业务代码只看到:
// ["ask", "你是谁"]4. 命令分发
runCli() 做第一层分发。
async function runCli(argv) {
const [command, ...rest] = argv;
if (command === "hello") {
console.log("HarnessX CLI is running.");
return;
}
if (command === "ask") {
// rest 是 ["你是谁"]
// join 之后得到真正要发给模型的 prompt。
const prompt = rest.join(" ").trim();
await askOnce(prompt);
return;
}
if (command === "chat") {
// chat 会在一个进程里持续维护 messages。
await chat(rest.join(" ").trim());
return;
}
}- 这里要记住:
hello只验证入口。ask做一次模型请求。chat做多轮模型请求。
5. 发出去的是什么
askOnce() 先把用户输入变成 messages。
async function askOnce(prompt) {
const messages = [
systemMessage(),
{ role: "user", content: prompt },
];
const answer = await callDeepSeek(messages);
console.log(answer);
}假设用户输入:
hx ask "你是谁"那发给 DeepSeek 的核心请求体就是:
{
"model": "deepseek-chat",
"messages": [
{
"role": "system",
"content": "你是 HarnessX 第 0 课的对话助手。\n用中文回答,保持简短具体。\n当前只验证 Node.js CLI 入口和 DeepSeek 多轮对话。"
},
{
"role": "user",
"content": "你是谁"
}
],
"stream": false
}- 这就是最关键的东西:
model:告诉服务端调用哪个模型。messages:告诉模型当前对话上下文。stream: false:这次先等完整回答返回。
代码里对应这一段。
const requestBody = {
model,
messages,
stream: false,
};6. 怎么发出去
真正发出去的是 fetch()。
const response = await fetch("https://api.deepseek.com/chat/completions", {
method: "POST",
headers: {
// 告诉服务端:我发的是 JSON。
"Content-Type": "application/json",
// 告诉服务端:我是谁,我有没有权限调用。
Authorization: `Bearer ${apiKey}`,
},
// HTTP 请求体必须是字符串,所以要 JSON.stringify。
body: JSON.stringify(requestBody),
});- 这段代码说明 Agent 的底层动作:
- 程序组织上下文。
- 程序带上 API Key。
- 程序向一个 URL 发 HTTP POST。
如果要用更朴素的话讲:
我把聊天记录整理成 JSON,
带上 API Key,
POST 到 DeepSeek 的接口,
等它返回一个 JSON。7. 返回的是什么
DeepSeek 返回的是一个 JSON。
大概长这样:
{
"choices": [
{
"message": {
"role": "assistant",
"content": "我是 HarnessX 第 0 课的对话助手。"
}
}
]
}当前代码只取这一段:
const payload = await response.json();
const content = payload?.choices?.[0]?.message?.content;
return content;- 这里要记住:
response是 HTTP 响应。payload是响应 JSON。choices[0].message是模型给出的第一条回复。content是最终要打印给用户看的文本。
8. 多轮对话
多轮对话的本质是:每次请求都带上更长的 messages。
第一轮:
[
{ "role": "system", "content": "你是 HarnessX 第 0 课的对话助手。" },
{ "role": "user", "content": "我的名字是 liguwe" }
]模型回答后,代码把 assistant 也追加进去。
[
{ "role": "system", "content": "你是 HarnessX 第 0 课的对话助手。" },
{ "role": "user", "content": "我的名字是 liguwe" },
{ "role": "assistant", "content": "你好,liguwe。" }
]第二轮用户再问:
[
{ "role": "system", "content": "你是 HarnessX 第 0 课的对话助手。" },
{ "role": "user", "content": "我的名字是 liguwe" },
{ "role": "assistant", "content": "你好,liguwe。" },
{ "role": "user", "content": "我刚才说我的名字是什么?" }
]模型能答出来的原因,是代码每次都把历史消息重新发给它。
8.1. 上下文追加
INFO
聊天记录是在应用层一直追加的。
- 用户说一句,追加一条
usermessage。 - 模型回一句,追加一条
assistantmessage。 - 下一次请求时,程序把完整
messages重新发给模型。 - 所以聊得越久,上下文越长,token 成本越高。
- 长对话需要压缩,是因为上下文窗口和 token 成本有限。
- 自回归说的是模型生成回答的方式:基于前面的 token,一个 token 一个 token 往后生成。
8.2. 自回归
自回归解释的是模型怎么生成回答。
- 模型不是一次性吐出完整答案。
- 它会先看见输入上下文。
- 然后预测下一个最可能出现的 token。
- 预测出来的 token 会变成新的上下文。
- 模型再继续预测下一个 token。
- 这样一个一个往后生成,直到回答结束。
可以先把它理解成:
已有内容 -> 预测下一个 token -> 加回已有内容 -> 再预测下一个 token这里容易混淆的是:
messages一直追加:- 这是应用程序做的事情。
- 目的是让模型看到历史对话。
- 自回归生成:
- 这是模型内部生成回答的方式。
- 目的是把一句回答一个 token 一个 token 写出来。
这段代码就是关键:
// 用户说一句,追加到 messages。
messages.push({ role: "user", content: text });
// 把完整 messages 发给模型。
const answer = await callDeepSeek(messages);
// 模型回一句,也追加到 messages。
messages.push({ role: "assistant", content: answer });8.3. 终端里怎么实现多轮对话
一次性对话很简单:
hx ask "你是谁"程序拿到这句 prompt,请求一次模型,打印一次结果,然后进程结束。
- 多轮对话多出来的关键是:
- Node.js 进程不能马上结束。
- 终端要一行一行接收用户输入。
- 每收到一行,就调用一次模型。
- 每次调用前后,都维护同一个
messages。 - 打印完模型回答后,继续等下一行输入。
紧凑看,就是这个循环:
这里的核心不是大模型,是终端输入。
const rl = readline.createInterface({
// stdin 是终端输入:用户敲键盘进来的内容。
input: process.stdin,
// stdout 是终端输出:程序打印给用户看的内容。
output: process.stdout,
// TTY 表示现在是真人在终端里交互。
terminal: Boolean(process.stdin.isTTY && process.stdout.isTTY),
});真正让程序持续运行的是这个循环:
while (true) {
// 程序停在这里,等用户输入一行。
const line = await rl.question("hx> ");
// 处理这一行:
// - /exit:退出
// - /clear:清空上下文
// - 普通文本:发给模型
if (!(await handleLine(line))) {
return;
}
}handleLine() 负责把终端命令分开。
async function handleLine(line) {
const trimmed = line.trim();
if (trimmed === "/exit" || trimmed === "/quit") {
return false;
}
if (trimmed === "/clear") {
messages.splice(1);
console.log("上下文已清空。");
return true;
}
await ask(line);
return true;
}- 所以
hx chat的本质是:- 用
readline把终端变成一个输入循环。 - 用
messages保存当前进程里的对话历史。 - 用
callDeepSeek(messages)把历史上下文发给模型。 - 用
console.log(answer)把模型回答打回终端。
- 用
9. 怎么跑
先验证入口。
npm link
hx --help
hx hello看到这个输出,说明 CLI 入口通了。
HarnessX CLI is running.再配置模型。
cp .env.example .env.env 里填:
DEEPSEEK_API_KEY=你的 key
DEEPSEEK_MODEL=deepseek-chat跑一次 HTTP 调用。
hx ask "只回复 OK"预期看到:
OK跑多轮对话。
hx chat输入:
hx> 我的名字是 liguwe
hx> 我刚才说我的名字是什么?- 如果第二句能回答出来:
- 说明
messages被持续追加。 - 说明每次 HTTP 请求都带上了上下文。
- 说明
10. 这一课真正要记住
hx:- 只是触发 Node.js 程序的入口。
process.argv:- 把用户命令变成 JS 数组。
messages:- 把用户输入和历史上下文组织成模型能读的格式。
fetch():- 把
messages作为 HTTP 请求体发给 DeepSeek。
- 把
payload.choices[0].message.content:- 从 HTTP 响应里取出模型回答。
第 0 课最终要讲清楚的是这句话:
Agent 的起点,就是程序把上下文打包成 HTTP 请求发给大模型,
再从 HTTP 响应里取出模型的下一句话。11. 源码
这里保留当前版本的主流程,方便以后回看这一版是怎么跑通的。
11.1. 文件职责
package.json- 把
hx命令指向src/index.js。
- 把
src/index.js- 接收命令参数。
- 分发
hello、ask、chat。 - 组织单轮和多轮对话的
messages。
src/deepseek.js- 读取
.env。 - 组装 HTTP 请求。
- 调用 DeepSeek Chat Completions。
- 从返回 JSON 里取出 assistant 的文本。
- 读取
11.2. 主流程图
11.3. 命令入口
hx 能跑起来,先靠 package.json 里的 bin。
{
"bin": {
"hx": "./src/index.js"
}
}src/index.js 第一行交给 Node.js 执行。
`#!/usr/bin/env` node真正进入业务代码的是最后这一行。
// process.argv.slice(2) 只保留用户真正输入的业务参数。
// hx ask "你是谁" 进来后就是 ["ask", "你是谁"]。
runCli(process.argv.slice(2)).catch((error) => {
console.error(error.message);
process.exit(1);
});11.4. 命令分发
runCli() 是第一层路由。
async function runCli(argv) {
const [command, ...rest] = argv;
if (!command || command === "-h" || command === "--help") {
printHelp();
return;
}
if (command === "hello") {
console.log("HarnessX CLI is running.");
return;
}
if (command === "ask") {
const prompt = rest.join(" ").trim();
await askOnce(prompt);
return;
}
if (command === "chat") {
await chat(rest.join(" ").trim());
return;
}
}- 这里先记住三条线:
hello:只验证命令入口通了。ask:把一句话发给模型,拿一次回答。chat:让 Node.js 进程留在终端里,持续接收输入。
11.5. 单轮对话
askOnce() 做的事情很少。
async function askOnce(prompt) {
const messages = [
systemMessage(),
{ role: "user", content: prompt },
];
const answer = await callDeepSeek(messages);
console.log(answer);
}- 这段代码体现的逻辑是:
- 用户输入变成
usermessage。 - 程序补上一条
systemmessage。 - 两条 message 一起发给 DeepSeek。
- 模型返回答案后,直接打印到终端。
- 用户输入变成
11.6. 多轮对话
chat() 比 askOnce() 多了两个东西:
- 一个长期存在的
messages数组。 - 一个持续等待终端输入的
readline循环。
async function chat(initialPrompt) {
const messages = [systemMessage()];
async function ask(prompt) {
const text = prompt.trim();
if (!text) {
return;
}
messages.push({ role: "user", content: text });
const answer = await callDeepSeek(messages);
messages.push({ role: "assistant", content: answer });
console.log(answer);
}
}- 这里最关键的是:
messages定义在chat()里。- 只要这个 Node.js 进程还没退出,它就一直活着。
- 用户每问一句,就追加一条
user。 - 模型每回一句,就追加一条
assistant。
终端循环靠 readline。
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
terminal: Boolean(process.stdin.isTTY && process.stdout.isTTY),
});
while (true) {
const line = await rl.question("hx> ");
if (!(await handleLine(line))) {
return;
}
}- 这段代码的意思是:
- 程序停在
rl.question("hx> ")等用户输入。 - 用户按回车后,拿到一整行文本。
- 文本交给
handleLine()。 - 如果不是退出命令,就继续下一轮。
- 程序停在
11.7. 模型调用
真正访问 DeepSeek 的地方在 src/deepseek.js。
export async function callDeepSeek(messages) {
const requestBody = {
model,
messages,
stream: false,
};
const response = await fetch("https://api.deepseek.com/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify(requestBody),
});
const payload = await response.json();
const content = payload?.choices?.[0]?.message?.content;
return content;
}- 这段代码背后的完整逻辑是:
messages是这次要给模型看的上下文。requestBody是 HTTP 请求体。fetch()把请求体 POST 给 DeepSeek。response.json()把返回值解析成 JS 对象。choices[0].message.content是最终要给用户看的模型回答。
这一版源码压缩成一句话就是:
hx 命令进入 src/index.js,
runCli() 分发到 ask 或 chat,
ask/chat 组织 messages,
callDeepSeek() 把 messages 发给 DeepSeek,
最后把 choices[0].message.content 打印回终端。