coldnew's blog

使用 C++ 實現一個簡單的 Coding Agent: 簡單的 chat 程式

使用 C++ 實現一個簡單的 Coding Agent: 從 curl 呼叫開始 一文中,我們說明了如何透過 curl 去做出簡單的 chat 功能,而在 使用 C++ 實現一個簡單的 Coding Agent: CMake 專案的建立 一文中我們知道如何使用 CMake 建立 C++ 的專案,現在是時候用 C++ 寫一個簡單的聊天系統了。

main.cpp : 程式的進入點

在寫 C/C++ 程式時,我們都會寫一個 main.cpp 作為程式的進入點。

那們我們要怎樣開始呢?從 使用 C++ 實現一個簡單的 Coding Agent: 從 curl 呼叫開始 一文中我們知道了我們會需要將我們的問題提供給 LLM, 接著收到後接收使用者輸入,再送過去給 LLM ,這樣一直來往直到使用者想要離開為止,所以我們可以規劃程式的運作大概像這樣的結構:

sequenceDiagram
    participant 使用者
    participant CLI 主程式
    participant 代理
    Note over CLI 主程式,代理: 程式初始化
    CLI 主程式->>代理: 建立代理 agent(url, key, model_name)
    CLI 主程式-->>使用者: 顯示 "Simple Agent - Enter your query (or 'quit' to exit):"
    loop 主迴圈
        CLI 主程式-->>使用者: 顯示「User > 」
        使用者->>CLI 主程式: 輸入查詢
        break 當查詢為 "quit"、"/quit"、"exit" 或 "/exit" 時
            CLI 主程式-->>使用者: 離開程式
        end
        alt 空白輸入
            CLI 主程式->>CLI 主程式: 如果 (query.empty())
            CLI 主程式-->>CLI 主程式: 繼續(新提示)
        else 有效查詢
            CLI 主程式->>代理: 結果 = agent.Run(query)
            代理-->>CLI 主程式: 回傳回應
            CLI 主程式-->>使用者: 輸出「AI > 」+ 結果
        end
    end

其中 Agent 是我們和 LLM 溝通的實作,我們會將他抽到 agent.cpp 裡面,因此在主程式是不需要介意他的存在,只要知道每一次收到資料,都會透過 agent.Run 去讓 agent 將資料送到遠端就好。

注意:

我們是 chat (聊天), 聊天就是要有來有往,不可以來來來或是往往往,因此我們會等 agent.Run 完成後才收 user 的輸入,所謂的 Agent Loop 就是這樣的概念

端點與模組指定

在開始聊天程式撰寫之前,我們需要獲得 LLM 端點的 URL 以及 KEYMODEL ,因此我們會先這樣用環境變數來指定這些資訊,而不是寫死,這是為了方便切換模組或是端點進行驗證

其中 API_URL 通常會是 http://URL/v1 的格式,但是實際上使用 chat 時候,目標端點會是 http://URL/v1/chat/completions ,所以我們會做一點轉換

const char* api_url = std::getenv("API_URL");
const char* api_key = std::getenv("API_KEY");
const char* model = std::getenv("MODEL");

if (!api_url || !api_key || !model) {
  std::cerr << "Please set API_URL, API_KEY, and MODEL environment variables"
            << std::endl;
  return -1;
}

std::string url, key, model_name;

url = std::string(api_url) + "/chat/completions";
key = api_key;
model_name = model;

這樣我們最後執行程式時,就可以這樣提供端點的資訊,這邊以 openrouter 並使用 openrouter/free 這個免費的模型作為範例:

API_KEY="$OPENROUTER_API_KEY" \
       API_URL="https://openrouter.ai/api/v1" \
       MODEL="openrouter/free" ./build/src/simple_agent

Agent Loop

知道了 API_URL, API_KEY, MODEL 後,我們會將它傳給 Agent 這個物件,接著著使用一個無窮的 while-loop 來跑我們的程式,終止條件就是有人輸入了 quit 等字眼

Agent agent(url, key, model_name);

std::cout << "Simple Agent - Enter your query (or 'quit' to exit):"
          << std::endl;

while (true) {
  std::cout << "\nUser > ";
  std::string query;
  std::getline(std::cin, query);

  if (query == "quit" || query == "/quit" || query == "exit" ||
      query == "/exit") {
    break;
  }

  if (query.empty()) {
    continue;
  }

  const std::string result = agent.Run(query);
  std::cout << "\nAI > " << result << std::endl;
}

記得嗎? 對話就是有來有往 ,所以我們每送出去一句話,就會等待 agent.Run 也就是等對方將訊息送回來。

agent.cpp : Agent 的處理

那我們要怎樣實作這個 Agent 呢,首先因為我們在 main.cpp 裡面是這樣呼叫 Agent 的

Agent agent(url, key, model_name);

所以很理所當然 Agent 在建構子需要將這些東西存成變數自己使用

Agent::Agent(const std::string& api_url,
             const std::string& api_key,
             const std::string& model)
    : api_url_(api_url), api_key_(api_key), model_(model) {}

Agent::~Agent() = default;

資料的送與收

使用 C++ 實現一個簡單的 Coding Agent: 從 curl 呼叫開始 一文我們是使用 curl 來進行資料的傳送,而在 C++ 上面我可以使用 libcurl 這個 curl 的函式庫來做出一樣的事情

我們先來講怎麼送資料,由於我們都是要送 json 資料過去,因此這個函式的參數會是一個 json 物件,而該物件在 message.h 裡面我們是這樣定義的

using Json = nlohmann::json;

所以我們送資料的方式,原本 shell 下面這樣的呼叫

curl https://openrouter.ai/api/v1/chat/completions \
  -H "Authorization: Bearer $OPENROUTER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '<JSON_DATA>'

就會變成將 curl 變成這樣的 C++ 呼叫,注意到因為 curl 是使用 callback 接收 data 的,因此我們等等還要實作 WriteCallback 函式才行,當 callback 完成後,會將資料寫到 resposne 這個字串內,然後我們再返回給呼叫者

(為了版面好看,這邊省略掉一些錯誤處理機制)

Json Agent::SendRequest(const Json& payload) {
  CURL* curl = curl_easy_init();

  std::string response;
  struct curl_slist* headers = nullptr;
  headers = curl_slist_append(headers, "Content-Type: application/json");
  headers = curl_slist_append(headers, "Accept: application/json");

  const std::string auth_header = "Authorization: Bearer " + api_key_;
  headers = curl_slist_append(headers, auth_header.c_str());

  const std::string request_body = payload.dump();

  curl_easy_setopt(curl, CURLOPT_URL, api_url_.c_str());
  curl_easy_setopt(curl, CURLOPT_POSTFIELDS, request_body.c_str());
  curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE,
                   static_cast<long>(request_body.size()));
  curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
  curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);

  // 注意: 這邊指定了接收資料用的 callback, 資料會寫回到 response 變數
  curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, WriteCallback);
  curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response);

  const CURLcode res = curl_easy_perform(curl);
  curl_slist_free_all(headers);
  curl_easy_cleanup(curl);

  return Json::parse(response); // 將 response 回傳成 Json 物件
}

那麼 WriteCallback 是怎樣實作呢?這邊就要參考 https://curl.se/libcurl/c/CURLOPT_WRITEFUNCTION.html ,我們可以看到原型是這樣的

#include <curl/curl.h>

size_t write_callback(char *ptr, size_t size, size_t nmemb, void *userdata);

CURLcode curl_easy_setopt(CURL *handle, CURLOPT_WRITEFUNCTION, write_callback);

所以對應的 C++ 版本就是這樣寫:

size_t Agent::WriteCallback(void* contents,
                            size_t size,
                            size_t nmemb,
                            void* userp) {
  const size_t realsize = size * nmemb;
  std::string* buffer = static_cast<std::string*>(userp);
  buffer->append(static_cast<char*>(contents), realsize);
  return realsize;
}

Agent 的 Run

那麼 Agent 的 Run 還要做什麼事呢?還記得我們之前在 使用 C++ 實現一個簡單的 Coding Agent: 從 curl 呼叫開始 用了大量的 jq 命令來解析 json 嗎? 在這邊, agent.Run 就是要做類似的事情,將使用這輸入變成 json 格式,並將收到的資料找出 .choices[0].message.content 的地方將其撈出來

因此我們要實作這樣的 curl 命令的組合

curl https://openrouter.ai/api/v1/chat/completions \
  -H "Authorization: Bearer $OPENROUTER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "openrouter/free",
    "messages": [
      {
        "role": "user",
        "content": "Hi, I am jimmy"
      }
    ]
  }' | jq -r '.choices[0].message.content'

記得在最後,我們要把之前的資料一起餵出去,這樣 AI 才會有記憶

std::string Agent::Run(const std::string& query,
                       const std::string& system_prompt) {
  Message user_msg;
  user_msg.role = Role::kUser;
  user_msg.content_type = ContentType::kText;
  user_msg.text = query;

  messages_.push_back(user_msg);

  Json payload;
  payload["model"] = model_;
  payload["max_tokens"] = 1024;

  Json msgs = Json::array();
  for (const auto& msg : messages_)
    msgs.push_back(MessageToJson(msg));
  payload["messages"] = msgs;

  const Json response = SendRequest(payload);
  if (response.contains("error"))
    return "Error: " + response["error"].dump();

  std::string content;
  if (response.contains("choices") && !response["choices"].empty()) {
    const auto& msg = response["choices"][0]["message"];
    if (msg.contains("content") && msg["content"].is_string()) {
      content = msg["content"].get<std::string>();
    } else {
      return "Error: Response content is not a string";
    }
  } else if (response.contains("content")) {
    if (response["content"].is_string()) {
      content = response["content"].get<std::string>();
    } else {
      return "Error: Unexpected content format";
    }
  } else {
    return "Error: Invalid response format: " + response.dump();
  }

  // update messages with assistant response so that the next query
  // will have the full conversation history
  Message assistant_msg;
  assistant_msg.role = Role::kAssistant;
  assistant_msg.content_type = ContentType::kText;
  assistant_msg.text = content;
  messages_.push_back(assistant_msg);

  return content;
}

在這邊,我們使用到寫在 messages.cpp 裡面一個函式,目的也是為了讓資訊簡化成 json 比較容易用

std::string RoleToString(Role r) {
  switch (r) {
    case Role::kUser:
      return "user";
    case Role::kAssistant:
      return "assistant";
    case Role::kSystem:
      return "system";
  }
  return "user";
}

Json MessageToJson(const Message& msg) {
  Json j;
  j["role"] = RoleToString(msg.role);
  j["content"] = msg.text;
  return j;
}

Role 分成 User, System, Assistant 這三種,其中 User 就是使用者, Assistant 則是 AI 本身, System 則是代表 System Prompt, 也就是我們怎們引導 AI 思考的基本 prompt, 因為這篇文章還用不到,所以我們先省略掉 system prompt 的部分

執行與測試

程式完成後,我們可以這樣編譯並進行測試

cmake -B build -S .    # 告訴 cmake 我們要建立 build 資料夾用來做編譯設定,編譯專案是當前資料夾
cmake --build build    # 進行編譯

這邊提供除了 openrouter 外,使用 ollama , lm-studio 的方式,如果是本地模型的話,可以使用 gemma:e4b ,效果不會太差

openrouter

openrouter 使用 openrouter/free 來進行測試,記得你要先設定好 OPENROUTER_API_KEY 才可以順利執行

API_KEY="$OPENROUTER_API_KEY" \
       API_URL="https://openrouter.ai/api/v1" \
       MODEL="openrouter/free" ./build/src/simple_agent

ollama

假設你要跑本地模型,並且你已經架設好 ollama 的話,則這樣,不過 APIKEY 需要依照你自己的需求進行修改

API_KEY="ollama" \
       API_URL="http://localhost:11434/v1" \
       MODEL="gemma4:e4b" ./build/src/simple_agent

lm-studio

如果你使用的是 lm-studio 的話,改成這樣執行,其中 APIKEY 需要依照你自己的需求進行修改

API_KEY="lmstudio" \
    API_URL="http://localhost:1234/v1" \
    MODEL="gemma4:e4b" ./build/src/simple_agent

範例程式碼

本文的程式碼已經上線,你可以用以下命令取得,對應的 tag 為 v0.2.0, happy coding :)

git clone https://github.com/coldnew/simple-agent.git
cd simple-agent
git checkout v0.2.0

展示

本文的成品展示如下:

On this page