214. 技术:HarnessX 第 0 课:先让 hx 命令跑起来、Agent 的第一性原理

2026.06.22

·技术harnessx

1. 重点

  • 第 0 课真正要看懂的是这条链路:
    • 用户敲 hx ask "你是谁"
    • Node.js 收到命令参数。
    • 代码把参数变成 messages
    • fetch() 发一个 HTTP POST 请求给 DeepSeek。
    • DeepSeek 返回 JSON。
    • 代码从 JSON 里取出 assistant 的文本。
  • Agent / Harness 往后会变复杂,但第一性原理很简单:
    • 本质就是一个程序。
    • 程序把上下文组织成 HTTP 请求。
    • 大模型根据请求返回下一句话。
    • 程序再决定把这句话显示出来,还是继续做别的事。

2. 流程图

2.1. HTTP 一来一回

214. 技术:HarnessX 第 0 课:先让 hx 命令跑起来、Agent 的第一性原理 图表 1

2.2. 多轮对话的一来一回

214. 技术:HarnessX 第 0 课:先让 hx 命令跑起来、Agent 的第一性原理 图表 2

3. 入口

先看命令怎么连到文件。

json
{
  "bin": {
    "hx": "./src/index.js"
  }
}
  • 这段配置只解决一件事:
    • 用户敲 hx
    • npm 找到 src/index.js
    • Node.js 执行这个文件。

文件第一行写:

js
`#!/usr/bin/env` node

// 这行告诉系统:
// 当前文件要交给 node 执行。

真正的参数入口是:

js
runCli(process.argv.slice(2));

// 用户输入:
// hx ask "你是谁"
//
// process.argv 大概是:
// [
//   "/path/to/node",
//   "/path/to/hx",
//   "ask",
//   "你是谁"
// ]
//
// slice(2) 之后,业务代码只看到:
// ["ask", "你是谁"]

4. 命令分发

runCli() 做第一层分发。

js
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

js
async function askOnce(prompt) {
  const messages = [
    systemMessage(),
    { role: "user", content: prompt },
  ];

  const answer = await callDeepSeek(messages);
  console.log(answer);
}

假设用户输入:

bash
hx ask "你是谁"

那发给 DeepSeek 的核心请求体就是:

json
{
  "model": "deepseek-chat",
  "messages": [
    {
      "role": "system",
      "content": "你是 HarnessX 第 0 课的对话助手。\n用中文回答,保持简短具体。\n当前只验证 Node.js CLI 入口和 DeepSeek 多轮对话。"
    },
    {
      "role": "user",
      "content": "你是谁"
    }
  ],
  "stream": false
}
  • 这就是最关键的东西:
    • model:告诉服务端调用哪个模型。
    • messages:告诉模型当前对话上下文。
    • stream: false:这次先等完整回答返回。

代码里对应这一段。

js
const requestBody = {
  model,
  messages,
  stream: false,
};

6. 怎么发出去

真正发出去的是 fetch()

js
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。

如果要用更朴素的话讲:

text
我把聊天记录整理成 JSON,
带上 API Key,
POST 到 DeepSeek 的接口,
等它返回一个 JSON。

7. 返回的是什么

DeepSeek 返回的是一个 JSON。

大概长这样:

json
{
  "choices": [
    {
      "message": {
        "role": "assistant",
        "content": "我是 HarnessX 第 0 课的对话助手。"
      }
    }
  ]
}

当前代码只取这一段:

js
const payload = await response.json();
const content = payload?.choices?.[0]?.message?.content;
return content;
  • 这里要记住:
    • response 是 HTTP 响应。
    • payload 是响应 JSON。
    • choices[0].message 是模型给出的第一条回复。
    • content 是最终要打印给用户看的文本。

8. 多轮对话

多轮对话的本质是:每次请求都带上更长的 messages

第一轮:

json
[
  { "role": "system", "content": "你是 HarnessX 第 0 课的对话助手。" },
  { "role": "user", "content": "我的名字是 liguwe" }
]

模型回答后,代码把 assistant 也追加进去。

json
[
  { "role": "system", "content": "你是 HarnessX 第 0 课的对话助手。" },
  { "role": "user", "content": "我的名字是 liguwe" },
  { "role": "assistant", "content": "你好,liguwe。" }
]

第二轮用户再问:

json
[
  { "role": "system", "content": "你是 HarnessX 第 0 课的对话助手。" },
  { "role": "user", "content": "我的名字是 liguwe" },
  { "role": "assistant", "content": "你好,liguwe。" },
  { "role": "user", "content": "我刚才说我的名字是什么?" }
]

模型能答出来的原因,是代码每次都把历史消息重新发给它。

8.1. 上下文追加

INFO

聊天记录是在应用层一直追加的。

  • 用户说一句,追加一条 user message。
  • 模型回一句,追加一条 assistant message。
  • 下一次请求时,程序把完整 messages 重新发给模型。
  • 所以聊得越久,上下文越长,token 成本越高。
  • 长对话需要压缩,是因为上下文窗口和 token 成本有限。
  • 自回归说的是模型生成回答的方式:基于前面的 token,一个 token 一个 token 往后生成。

8.2. 自回归

自回归解释的是模型怎么生成回答。

  • 模型不是一次性吐出完整答案。
  • 它会先看见输入上下文。
  • 然后预测下一个最可能出现的 token。
  • 预测出来的 token 会变成新的上下文。
  • 模型再继续预测下一个 token。
  • 这样一个一个往后生成,直到回答结束。

可以先把它理解成:

text
已有内容 -> 预测下一个 token -> 加回已有内容 -> 再预测下一个 token

这里容易混淆的是:

  • messages 一直追加:
    • 这是应用程序做的事情。
    • 目的是让模型看到历史对话。
  • 自回归生成:
    • 这是模型内部生成回答的方式。
    • 目的是把一句回答一个 token 一个 token 写出来。

这段代码就是关键:

js
// 用户说一句,追加到 messages。
messages.push({ role: "user", content: text });

// 把完整 messages 发给模型。
const answer = await callDeepSeek(messages);

// 模型回一句,也追加到 messages。
messages.push({ role: "assistant", content: answer });

8.3. 终端里怎么实现多轮对话

一次性对话很简单:

bash
hx ask "你是谁"

程序拿到这句 prompt,请求一次模型,打印一次结果,然后进程结束。

  • 多轮对话多出来的关键是:
    • Node.js 进程不能马上结束。
    • 终端要一行一行接收用户输入。
    • 每收到一行,就调用一次模型。
    • 每次调用前后,都维护同一个 messages
    • 打印完模型回答后,继续等下一行输入。

紧凑看,就是这个循环:

214. 技术:HarnessX 第 0 课:先让 hx 命令跑起来、Agent 的第一性原理 图表 3

这里的核心不是大模型,是终端输入。

js
const rl = readline.createInterface({
  // stdin 是终端输入:用户敲键盘进来的内容。
  input: process.stdin,

  // stdout 是终端输出:程序打印给用户看的内容。
  output: process.stdout,

  // TTY 表示现在是真人在终端里交互。
  terminal: Boolean(process.stdin.isTTY && process.stdout.isTTY),
});

真正让程序持续运行的是这个循环:

js
while (true) {
  // 程序停在这里,等用户输入一行。
  const line = await rl.question("hx> ");

  // 处理这一行:
  // - /exit:退出
  // - /clear:清空上下文
  // - 普通文本:发给模型
  if (!(await handleLine(line))) {
    return;
  }
}

handleLine() 负责把终端命令分开。

js
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. 怎么跑

先验证入口。

bash
npm link
hx --help
hx hello

看到这个输出,说明 CLI 入口通了。

text
HarnessX CLI is running.

再配置模型。

bash
cp .env.example .env

.env 里填:

env
DEEPSEEK_API_KEY=你的 key
DEEPSEEK_MODEL=deepseek-chat

跑一次 HTTP 调用。

bash
hx ask "只回复 OK"

预期看到:

text
OK

跑多轮对话。

bash
hx chat

输入:

text
hx> 我的名字是 liguwe
hx> 我刚才说我的名字是什么?
  • 如果第二句能回答出来:
    • 说明 messages 被持续追加。
    • 说明每次 HTTP 请求都带上了上下文。

10. 这一课真正要记住

  • hx
    • 只是触发 Node.js 程序的入口。
  • process.argv
    • 把用户命令变成 JS 数组。
  • messages
    • 把用户输入和历史上下文组织成模型能读的格式。
  • fetch()
    • messages 作为 HTTP 请求体发给 DeepSeek。
  • payload.choices[0].message.content
    • 从 HTTP 响应里取出模型回答。

第 0 课最终要讲清楚的是这句话:

text
Agent 的起点,就是程序把上下文打包成 HTTP 请求发给大模型,
再从 HTTP 响应里取出模型的下一句话。

11. 源码

这里保留当前版本的主流程,方便以后回看这一版是怎么跑通的。

11.1. 文件职责

  • package.json
    • hx 命令指向 src/index.js
  • src/index.js
    • 接收命令参数。
    • 分发 helloaskchat
    • 组织单轮和多轮对话的 messages
  • src/deepseek.js
    • 读取 .env
    • 组装 HTTP 请求。
    • 调用 DeepSeek Chat Completions。
    • 从返回 JSON 里取出 assistant 的文本。

11.2. 主流程图

214. 技术:HarnessX 第 0 课:先让 hx 命令跑起来、Agent 的第一性原理 图表 4

11.3. 命令入口

hx 能跑起来,先靠 package.json 里的 bin

json
{
  "bin": {
    "hx": "./src/index.js"
  }
}

src/index.js 第一行交给 Node.js 执行。

js
`#!/usr/bin/env` node

真正进入业务代码的是最后这一行。

js
// process.argv.slice(2) 只保留用户真正输入的业务参数。
// hx ask "你是谁" 进来后就是 ["ask", "你是谁"]。
runCli(process.argv.slice(2)).catch((error) => {
  console.error(error.message);
  process.exit(1);
});

11.4. 命令分发

runCli() 是第一层路由。

js
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() 做的事情很少。

js
async function askOnce(prompt) {
  const messages = [
    systemMessage(),
    { role: "user", content: prompt },
  ];

  const answer = await callDeepSeek(messages);
  console.log(answer);
}
  • 这段代码体现的逻辑是:
    • 用户输入变成 user message。
    • 程序补上一条 system message。
    • 两条 message 一起发给 DeepSeek。
    • 模型返回答案后,直接打印到终端。

11.6. 多轮对话

chat()askOnce() 多了两个东西:

  • 一个长期存在的 messages 数组。
  • 一个持续等待终端输入的 readline 循环。
js
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

js
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

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 是最终要给用户看的模型回答。

这一版源码压缩成一句话就是:

text
hx 命令进入 src/index.js,
runCli() 分发到 ask 或 chat,
ask/chat 组织 messages,
callDeepSeek() 把 messages 发给 DeepSeek,
最后把 choices[0].message.content 打印回终端。