第 01 章:第一次对话

所有 coding agent 的起点都朴素得惊人:一个 HTTP 请求。这一章我们写 70 行代码,实现一个能多轮对话的终端聊天程序。别小看它——理解这一章,后面的一切都是自然延伸。

核心认知:messages 数组就是全部状态

OpenAI 兼容的 chat completions 接口是无状态的。所谓"多轮对话",是客户端每轮把全部历史重新发给模型:

POST {baseUrl}/chat/completions
{
  "model": "deepseek-chat",
  "messages": [
    { "role": "system",    "content": "你是……" },
    { "role": "user",      "content": "你好" },
    { "role": "assistant", "content": "你好!有什么可以帮你?" },
    { "role": "user",      "content": "我刚才说了什么?" }
  ]
}

模型回复一条 assistant 消息,我们把它追加进 messages,下一轮再全部发出去。这个"追加 → 全量重发"的循环带来两个深远的影响,请记住它们,后面两章都与此有关:

  1. 上下文会无限增长——所以第 07 章需要压缩(compaction)。
  2. 历史必须由我们自己保管——所以第 06 章需要会话持久化。

pi 的 pi-ai 把这个结构抽象为 Context { systemPrompt, messages, tools },可以 JSON 序列化、可以跨 provider 交接(参见设计思想:LLM 抽象层)。我们的裸写版直接用这个 wire 格式本身,零抽象。

代码

src/main.ts(第 01 章完整版)
import * as readline from "node:readline";

// ── 配置 ──
const CONFIG = {
  baseUrl: (process.env.MINI_PI_BASE_URL ?? process.env.OPENAI_BASE_URL ?? "https://api.openai.com/v1").replace(
    /\/$/,
    "",
  ),
  apiKey: process.env.MINI_PI_API_KEY ?? process.env.OPENAI_API_KEY ?? "",
  model: process.env.MINI_PI_MODEL ?? "gpt-4o-mini",
};

interface Message {
  role: "system" | "user" | "assistant";
  content: string;
}

/** 非流式调用:发 messages,拿回整段回复 */
async function chatOnce(messages: Message[]): Promise<string> {
  const res = await fetch(`${CONFIG.baseUrl}/chat/completions`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${CONFIG.apiKey}`,
    },
    body: JSON.stringify({ model: CONFIG.model, messages }),
  });
  if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
  const data = (await res.json()) as any;
  return data.choices[0].message.content ?? "";
}

// ── REPL ──
async function main(): Promise<void> {
  if (!CONFIG.apiKey) {
    console.error("Error: set MINI_PI_API_KEY (or OPENAI_API_KEY) first.");
    process.exit(1);
  }

  const messages: Message[] = [{ role: "system", content: "You are a helpful assistant. Be concise." }];
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: "\n> " });

  rl.on("line", (line) => {
    const text = line.trim();
    if (!text) return rl.prompt();
    messages.push({ role: "user", content: text });
    chatOnce(messages)
      .then((reply) => {
        messages.push({ role: "assistant", content: reply });
        console.log(reply);
      })
      .catch((err) => console.error(`[error] ${err.message}`))
      .finally(() => rl.prompt());
  });

  rl.prompt();
}

main();

运行:

export MINI_PI_API_KEY=sk-...
npx tsx src/main.ts
> 你好,我是新来的
你好!有什么可以帮你?

> 我刚才说我是什么?
你刚才说你是新来的。

第二轮回答证明了:模型"记得"第一轮,是因为我们把第一轮的消息重发了一遍。

本章要点

  • chat completions 是无状态 API;messages 数组是客户端维护的全部对话状态。
  • 三种基本角色:system(行为设定)、user(输入)、assistant(模型回复)。第 02 章会迎来第四种:tool
  • 没有任何 SDK——一个 fetch 就是 LLM 调用的全部。
成本提示

全量重发意味着每轮的 input token 都在累加。真实 harness(包括 pi)会非常在意缓存命中(prompt caching)和上下文裁剪,这也是设计思想篇反复强调"token 是预算"的原因。

下一章:第 02 章:agent loop —— 让模型调用工具