qi-harness 快速开始:用奇语写一个可观测的 LLM Agent

底层调一次大模型很容易,难的是把它包进可观测、可重试、可评估的外壳里。qi-harness 就是奇语 Qi 的那层外壳 —— 一行配置任意 OpenAI 兼容端点,一次对话、流式、工具调用自动派发循环、追踪、退避重试,全用中文关键字写成。

qi-harness Agent 循环

qi-harness 解决什么问题

奇语标准库里的 标准库.大模型 已经能「调一次模型」:给它一段提示,拿回一段文本。但真实的 Agent 不止于此 —— 你需要知道每次调用花了多久、模型有没有要求调工具、限流时要不要退避重试、改了提示词后行为是否回归。这些「外壳」工作,正是 qi-harness 的职责。

它的设计借鉴了 Martin Fowler 的 harness engineering 思路:把不确定的 LLM 调用包在确定的、可调试的工程外壳里。具体提供四件事:

配置与凭证

qi-harness 是一个用奇语写成的库,作为跨包依赖通过 destructure 导入。配置任何 OpenAI 兼容端点只需一行 大模型(端点, 密钥, 模型),DeepSeek、OpenAI、本地推理服务都一样。

配置片段.qi
导入 标准库.操作系统 作为 系统;
导入 Harness::{ 模型配置, 大模型, 配置系统提示, 配置温度, 开启会话, 关闭会话Harness };

// 密钥从环境变量读 —— 永远不要硬编码
变量 密钥: 字符串 = 系统.获取环境变量("QI_LLM_KEY");

变量 配置: 模型配置 = 大模型("https://api.deepseek.com", 密钥, "deepseek-chat");
配置 = 配置系统提示(配置, "你是 qi 编程语言的简洁中文助手。");
配置 = 配置温度(配置, "0.3");     // 可选;还有 配置最大令牌(配置, 2048)

变量 会话: 整数 = 开启会话(配置);   // 返回 >0 即成功
提示 API 密钥必须从环境变量读取,绝不写进源码。qi-harness 的所有官方示例都先 系统.获取环境变量("QI_LLM_KEY"),密钥为空就优雅退出。本文的示例还会回退读取 DEEPSEEK_API_KEY,方便复用已有环境。运行需要一个有效的密钥和网络;没有它你看不到真实的模型回复(见文末「运行说明」)。

第一个对话

会话开好之后,用它创建一个「代理」。最简单的用法是 简单问:一次性问答,不带工具,返回模型的完整文本。

第一个对话.qi
导入 Harness::{ 创建代理, 设置最大步数, 简单问, 关闭代理 };

变量 代理值: 代理 = 创建代理("教程助手", 会话);
代理值 = 设置最大步数(代理值, 6);   // 工具循环上限,防失控(默认 10)

变量 回复: 字符串 = 简单问(代理值, "用一句话介绍 qi 编程语言。");
IO.打印行(回复);

关闭代理(代理值);
提示 关闭会话用的是 关闭会话Harness,不是 关闭会话 —— 这是 qi-harness 刻意保留的显式命名。代理用完先 关闭代理,会话用完再 关闭会话Harness(会话)。

流式输出

想边生成边显示,用 流式问。它接受第三个参数:一个块回调,签名固定为 函数(字符串): 整数 —— 每收到一个片段就回调一次,返回 1 表示继续接收。函数返回值仍是拼好的完整文本。

流式.qi
导入 Harness::{ 流式问 };
导入 Harness.追踪 作为 追踪;

// 块回调:片段实时打到 stdout,返回 1 继续
函数 打印片段(片段: 字符串) : 整数 {
    IO.打印(片段);
    返回 1;
}

// 关掉 trace 即时打印,避免和流式片段输出交错
追踪.禁用即时打印();
变量 完整: 字符串 = 流式问(代理值, "用两句话解释什么是编译器。", 打印片段);

工具调用循环

这是 qi-harness 的核心价值。你定义若干工具(每个工具 = 一个 JSON Schema + 一个处理函数),加进代理后调用 运行。如果模型决定调用工具,harness 会自动派发到你的处理函数、把结果回写进对话、再次请求模型,循环直到模型给出最终文本。模型一轮返回多个 tool_calls(parallel tool calls)时,会逐个派发各自回写。

工具处理函数签名固定为 函数(参数JSON: 字符串): 字符串,返回值必须是会被喂回模型的 JSON 字符串。

工具循环.qi
导入 标准库.字符串;
导入 Harness::{ 工具, 添加工具, 运行 };

// 工具实现:解析 args JSON,返回 JSON 结果
函数 天气查询(参数JSON: 字符串) : 字符串 {
    如果 (字符串::包含(字符串::转小写(参数JSON), "beijing") == 1) {
        返回 "{\"city\":\"Beijing\",\"temp\":24,\"condition\":\"多云\"}";
    }
    返回 "{\"error\":\"city not found\"}";
}

// 在 入口() 里:定义工具并加进代理
变量 天气工具: 工具 = (工具 {
    名字: "天气查询",
    描述: "查询指定城市的当前天气。",
    参数schema: "{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"]}",
    处理: 天气查询,
});
代理值 = 添加工具(代理值, 天气工具);

// 运行:自动 detect tool_calls → 派发 → 回写 → 继续,直到最终文本
变量 回复: 字符串 = 运行(代理值, "北京现在天气怎样?用中文回答。");
IO.打印行(回复);
提示 设置最大步数(代理, N) 给工具循环设上限,模型若一直要求调工具不给最终答案,到达上限就停止。默认 10,调试期可设小一点。

可观测与重试

追踪默认开启:每次 LLM 调用、每次工具派发都会 emit 一条 JSON 事件到 stdout。事件类型包括 llm_call / llm_response / tool_call / tool_result / error / done / stream_start / stream_chunk / stream_end。这让 Agent 像普通服务一样可以观察、记录、排查。

追踪.qi
导入 Harness.追踪 作为 追踪;

追踪.禁用即时打印();   // 只停实时打印(和流式回调混输出时用)
追踪.禁用();           // 完全关闭追踪

重试用 重试调用(策略, 代理名, 调用) 包裹任意「返回字符串的零参函数」。默认策略() 给出 3 次重试、起始 100ms、指数翻倍的退避。注意:奇语顶层命名函数不捕获局部变量,所以被包裹的「调用」要写成命名函数,通过模块级全局拿到会话。

重试.qi
导入 Harness::{ 重试策略, 默认策略, 重试调用, 创建代理, 简单问, 关闭代理 };

变量 全局会话: 整数 = 0;   // 入口里赋值为已开的会话

// 被重试包裹的「调用」:零参,返回字符串
函数 问关键字() : 字符串 {
    变量 临时代理: 代理 = 创建代理("重试助手", 全局会话);
    变量 答: 字符串 = 简单问(临时代理, "qi 语言用什么关键字声明变量?");
    关闭代理(临时代理);
    返回 答;
}

// 在 入口() 里:
变量 策略: 重试策略 = 默认策略();
变量 回复: 字符串 = 重试调用(策略, "教程助手", 问关键字);

运行说明

把上面所有片段拼成的完整示例放在仓库的 qi-harness/examples/教程_快速开始.qi。用编译器二进制运行:

终端
$ QI_LLM_KEY=sk-... qi run qi-harness/examples/教程_快速开始.qi
提示 想看到真实的模型回复,你需要一个有效的 API 密钥(QI_LLM_KEY 或 DEEPSEEK_API_KEY)以及网络连接。本文写作时验证环境里只有一个无效密钥,因此不展示任何模型的对话文本 —— 那需要你自己的有效凭证才能看到,我们不会编造。

不过,整个程序的结构和管线是真实验证过的:示例 qi check 通过(「所有文件语法检查通过」),并且 qi run 能编译、链接、执行到底 —— 追踪外壳真实地发出了事件。下面是真实跑出来的追踪输出(密钥无效,所以 llm_response 里是 401 鉴权错误,而不是模型回复;这恰好演示了追踪如何让失败一目了然):

真实追踪输出(密钥无效时)
$ qi run qi-harness/examples/教程_快速开始.qi
=== qi-harness 快速开始 ===

--- 一次性问答 ---
{"ts":...,"type":"llm_call","agent":"教程助手","detail":"用一句话介绍 qi 编程语言。"}
{"ts":...,"type":"llm_response","agent":"教程助手","detail":"LLM调用失败: API返回错误 401 Unauthorized: ... Authentication Fails ..."}
{"ts":...,"type":"done","agent":"教程助手","detail":"LLM调用失败: ... 401 ..."}

换成有效密钥后,llm_response 的 detail 就会是模型的真实文本,工具循环会出现 tool_call / tool_result 事件对。结构不变,只是内容从错误换成真实回复。

下一步