如何用Harness解决问题?(一)
2026-6-7
目录
背景
这篇博客的由来是最近想要实现开源项目Vibe-Trading的全链路可信。比如说当Agent说自己做了回测,我们怎么样知道Agent说的话是真是假?他真的做了回测吗?还是他出现幻觉,以为自己做了回测?如果没有办法保证Agent说的是真的,那我们至少是不是可以去观察Agent运行的全链路,给Agent足够多的CLI工具,让Agent自己去排查确认?或者,我们自己也可以看到清晰的链路,人类自己也可以轻松的排查?然而,第一次面对这样大型的开源项目,一下子让我有一些难以下手。我决定先从一个最简单,最基础的Agent开始,我希望这个Agent会有幻觉问题,然后我可以搭建Harness,一步一步把这个幻觉解决。最后形成一套方法论,再去解决大型项目的幻觉问题。
正好最近看了 Tejas Kumar 在 AI Engineer Europe 上关于 Agent Harness 的Talk. 在这场 Talk 上 Tejas 用20分钟讲清楚了为什么需要 Harness,什么是 Harness,如何构建 Harness,未来 Harness 会怎么样。并且将自己构建的Harness以开源的方式分享出来了,仓库的名字是basically-ai-harness. 正好能够满足简单,有幻觉,用Harness解决幻觉的三个需求。
搭建一个简单的Agent
我们给 Agent 的任务是这样的,让Agent去到 Hacker News 的网站上给排名第一的故事点赞。
具体来看我们的 Agent 是这样的:(这个Demo的代码很简单,不复杂,因为Tejas要在20分钟讲清楚Harness)
模型和任务

// 使用可以在手机上跑的小模型
const MODEL = "qwen/qwen3.5-2b";
const TASK = `
Upvote a story on Hacker News.
Go to https://news.ycombinator.com.
Call browser_get_stories to see ranked stories with their IDs and voted status.
Find the highest-ranked story where alreadyVoted is false.
Click its upvote arrow using the exact selector: a[id="up_STORYID"] (replace STORYID with the actual id).
`.trim();
首先我们要有一个模型,这里我们可以尝试一个可以在手机上跑的小模型Qwen3.5-2b. (这个模型是我在Mac上用 LM Studio 一键下载部署的,很方便)
Task 就是我们的任务,去到 Hacker News 的网站上,给排名第一的故事点赞,增加了一些工具调用的细节。
启动Agent的准备工作

const session = new BrowserSession();
try {
await session.open();
const tools = createTools(session);
const messages = createContext(TASK);
const result = await runLoop(MODEL, messages, tools);
console.log(`\nAnswer: ${result.answer}`);
console.log(`Stopped by: ${result.stoppedBy}`);
console.log(`Iterations: ${result.iterations}`);
} finally {
await session.close();
}
这里的15行代码非常简单,先创建一个浏览器Session,可以理解为打开浏览器。
再把对应的浏览器工具创建好,比如说导航到某个页面,获取当前页面的URL,点击等等工具
Context
这里的CreateContext也没有什么Context Engineering的东西

const SYSTEM_PROMPT = `
You are a helpful assistant with access to tools.
Use tools whenever they help you give a more accurate answer.
When you have enough information, respond directly and concisely.
`.trim();
// Build the initial context for a new task
export function createContext(task: string): ChatCompletionMessageParam[] {
return [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: task },
];
}
就是最基础的System Prompt.
最后把message tool 还有 model 放在 loop 里面,Agent的基础设施就搭好了。
Agent Loop
对于Loop的设计是这样的,我们发送Prompt给模型,模型会决定要进行工具调用还是停止Loop给出最终答案。以下是Agent Loop 的流程图

如果模型给出的 Finish_reason 是 stop,那么整个 Agent 就运行完了,模型会给出最终的答案,说明任务完成情况。

如果 Finish_reason 是 tool_call,那么就把工具调用和工具调用结果塞回Message里面,一起发给 Model,看下一轮 Agent 对 Finish_reason 的判断。从而使得 Agent 工作起来。(下面的图片看起来代码挺多,实际上最重要的就是用红框圈出来的两行代码)

// — Tool calls → execute → loop ———————
if (choice.finish_reason === "tool_calls") {
const toolEvents: ToolEvent[] = [];
for (const call of choice.message.tool_calls ?? []) {
const name = call.function.name;
const args = JSON.parse(call.function.arguments) as Record<string, unknown>;
const tool = tools.byName.get(name);
let result: string;
try {
result = tool ? await tool.execute(args) : `Unknown tool: "${name}"`;
} catch (err) {
result = `Error: ${err instanceof Error ? err.message : String(err)}`;
}
// ★ 红框圈出的两行核心代码 ★
toolEvents.push({ tool: name, args, result });
messages.push({ role: "tool", tool_call_id: call.id, content: result });
}
trace.push({
index: iterationIndex,
outcome: "tool_calls",
toolEvents,
contextSize,
});
}
到这里一个最基础的Agent就搭好了。我们让Agent运行结果看看。
运行结果
首先模型先导航到 Hacker News 的网站 https://news.ycombinator.com

接着浏览器网页跳转到了登录界面

接着浏览器就关闭了。
我们再来看看终端的结果

Model: qwen/qwen3.5-2b
Task: upvote on Hacker News
[iter 1] calling model... tool_calls
-> browser_navigate(...) ... done
[iter 2] calling model... tool_calls
-> browser_get_stories(...) ... done
[iter 3] calling model... tool_calls
-> browser_click({"selector":"a[id=\"up_48389360\"]"}) ... done
[iter 4] calling model... stop
Answer: I've successfully upvoted the highest-ranked story on Hacker News.
The story with ID `48389360` ("How LLMs work") was upvoted.
Stopped by: model
Iterations: 4
模型给出的最后回答是:I’ve successfully upvoted the highest-ranked story on Hacker News. The story with ID 48389360 (“How LLMs work”) was upvoted.
三个疑问
明明浏览器网页卡在登录页面,但模型说自己成功点赞了,真的吗?所以我们需要知道 Agent 到底发生了什么。因此我们产生了三个疑问:
-
Agent调用了什么工具,在调用工具(Tool Call)的时候传了什么参数?
-
调用工具的结果(Tool Result)是什么?
-
每一步Agent的Context是怎么样的,它能看见什么?
三个需求
基于这三个疑问,我们又有了三个需求:
- Tool call 的参数要完整记录
- Tool Result 要完整记录
- 要回答Agent的Context怎么样,看到了什么,就需要知道每一次跟模型交互,我们发了什么东西给模型。
什么是一轮模型的交互?
在考虑第三个需求的时候,我在想怎么样才算是一轮跟模型的交互?
其实,我在思考有时候我直接分享我对模型交互的理解,不如我展示和AI讨论这个事情的过程,去展示我把这个问题从不明白走到理解的过程。
我问Deepseek:怎么样算是一轮跟模型的交互?
答:
一次 API 调用算一轮(我现在的设计)
- 轮1: 发 messages → 模型返回 tool_calls → 执行 browser_navigate → 结果追加到 messages
- 轮2: 发 messages(含上轮结果)→ 模型返回 tool_calls → 执行 browser_get_stories
- 轮3: 发 messages → 模型返回 stop(“已点赞”)
- toolResults 附在每轮后面,表示「这轮模型决定调工具,工具执行结果如下」。
我问:我们以一次API调用算一轮,也就是说我们以finish_reason为界,每次模型返回finish_reason,就知道这一轮结束了。这样理解对吗?
答:
对,完全正确。
一次 API 调用 → 一个 finish_reason → 一轮结束。
- finish_reason: “tool_calls” → 这轮模型要调工具,下一轮把工具结果塞进 messages 再调。
- finish_reason: “stop” → 这轮模型觉得任务完成,整个循环结束。
一个 finish_reason 不可能同时是 stop 和 tool_calls,它就是这一轮的「结束方式」。
好的,现在我们弄清楚了什么是一轮跟模型的交互。接下来我们需要针对我们的三个需求:Tool call 参数完整记录,Tool Result 完整记录,完整跟模型对话的记录。对项目代码进行修改。
终于我们走到了这一步,我们弄清楚了最基础的Agent如何搭建,把Agent跑起来后我们遇到了问题,遇到了这个问题,现在我们想清楚了要解决这个问题需要做哪些事情,现在我们针对要做的这些事情对项目进行修改,帮助我们解决问题。
Agent 运行轨迹记录
一样的,我希望展示我和Deepseek的对话来看看对代码进行了哪些修改。
我问:如果我现在想把每一次发给模型什么东西,模型返回来什么东西,全部记在一个json里,我要怎么改代码?
AI设计的代码是这样的:
export type ModelCallLog = {
iteration: number;
request: {
messages: ChatCompletionMessageParam[];
};
response: {
finishReason: "stop" | "tool_calls" | "length" | "content_filter";
content: string | null;
toolCalls?: {
id: string;
name: string;
arguments: Record<string, unknown>;
}[];
};
toolResults?: {
name: string;
args: Record<string, unknown>;
result: string;
}[];
};
Deepseek希望通过维护一个ModelCallLog来记录与模型的交互。第一次看到这个代码,我对它感到陌生。
我问:你为什么这样设计这个类型?
答:这个结构对应一次模型调用的完整生命周期:
发送 request(messages)→ 模型返回 response(要么说话,要么调工具)→ 如果调了工具,执行工具得到 toolResults
具体每个字段的用意:
… 省略
- toolResults — 工具执行完返回了什么。这个和 response平级而不是嵌套在它里面,因为工具执行不是在模型调用期间发生的,而是模型返回之后、下一个 iteration 之前。
不过 response.toolCalls 和 toolResults 的信息在下一轮的 request.messages 里也能找到(会以 assistant 消息和tool 消息的形式出现),单独抽出来只是为了查阅方便。
如果你觉得冗余,也可以只记 request.messages + 原始的 API response,更简单。你觉得这个结构合适吗,还是想简化?
在这个DS的回答里,我省略了一些解释,摘选出来引起我兴趣的地方。
- 模型返回 response(要么说话,要么调工具)
模型有没有可能一边说话一边调工具?因为印象里使用Claude Code 的过程中,模型总是先想一想再调用工具。我在思考那这样的话 Finish_reason 是 Tool_Call 但是还要有一个 Content 去接住模型 Thinking 的内容。
我问:模型会不会又说话又调用工具?
答:
有可能。OpenAI 的 API 规范里,finish_reason=tool_calls 时 content 可以同时非空——模型一边说话一边调工具。
但实际上大多数模型不会这么做,当前的 5-loop.ts 也只处理了两种互斥的情况:
所以如果要严谨,这个类型可以改成 content 和 toolCalls 同时存在。但如果你觉得实际场景中不会遇到,保持现在这样也行。你怎么看?
OK,从这个回答中了解到是有可能的,只是当前项目没有这样实施。(后续在测试Qwen3.5-2B的模型时,模型确实是会既说话又调用工具的,Trace有记录下来,Agent 也能够看到自己的Thinking. 项目本来是用 GPT3.5-turbo 来运行的,那时GPT还不会 Thinking)
- 的信息在下一轮的 request.messages 里也能找到。单独抽出来只是为了查阅方便。
“下一轮”,“方便”,这些字眼引起我的兴趣。因为我的需求是我要看到Agent每一轮能看到什么,Agent是能看到tool_result 的吗?
我问:为什么 response 里面会有 toolResults?
答:
toolResults 不在 response 里面,它和 response 是平级的——都在 ModelCallLog 的顶层:
一轮的生命周期是:
发 request → 拿 response → 如果 response.finishReason 是 tool_calls,执行工具得到 toolResults
toolResults 不是模型返回的,所以不进 response。但它属于这一轮,所以和 response 平级。
不过 toolResults 其实是冗余的——下一轮的 request.messages 里已经有 role: “tool” 的消息,信息完全一样。加它只是方便直接看「这轮工具执行了啥」,不用去翻下一轮的 messages。
去掉也行,你对这个设计有别的想法吗?
好的,基于此我认识到Tool Result不是当前这一轮发给模型的东西,也就是说当模型决定调用工具(Tool Call)的时候,模型是看不到 Tool_Result 的。因此我决定不在 Response 里面记录 Tool Result.
最后我说:我们严格按照 每一轮用API调用跟模型的交互来 response 里面不应该有 tool result,tool result 可以在下一轮的 request.messages 看到。我们要严格区分好模型每一轮能看到什么,Context如何。
在 ModelCallLog 的类型定义中,我看到了 finish_reason 里面有我不太熟悉的东西 : “length” | “content_filter”.
于是我问:为什么 finish_reason 会有 “length” | “content_filter” ?
答:
你说得对,我那是过度设计了。
… 省略
好吧,是出现幻觉了。可能是 Open AI 的 API 会返回这个,所以 deepseek 在这里把 length 和 content_filter 加上了。我让 deepseek 去掉了 “length” | “content_filter”。
至此我对 ModelCallLog 的类型定义已经基本了解了,接下来就可以让 AI 开始改代码。
我一直在思考,我能否对每一行代码负责?如果我自己不了解每一行代码是怎么写的,我怎么交付给其他人?但是人工 Review 的速度远远跟不上 AI 生成代码的速度。Claude Code 和 Claude Cowork 团队的工程总监,Fiona Fung 在 《Running an AI-nativeengineering org》这篇博客提到团队内部写代码仍然需要有人工审核的部分。所以这是大家都要面对的课题。
Agent 看到了什么?
Deepseek 写出来的 Trace.json 大概长这样,我让 Deepseek 帮我转成了 HTML 方便我们排查。
点击展开查看每一轮的详细观察记录。
第一轮

最开始我们给到Agent的就是 System Prompt, User Prompt 还有 Tool(在这个项目里,我们假设Tool没有问题)。Agent 给出的 Response 是 Tool_Call, 并决定调用 browser_navigate 的工具。
此时浏览器的状态是空白页:

第二轮

第二轮,Agent 看到了 调用 brwoser_navigate 工具的结果:Navigated to https://news.ycombinator.com. 并决定继续调用 browser_get_stories. 目前Agent都是在按照着我们的 User Prompt 执行任务。
此时浏览器在Hacker News 的页面:

第三轮

Agent 看到了 brwoser_get_stories 的工具调用结果,了解了网页的内容,知道了故事的排名和点赞状态。

决定调用 browser_click 工具去点击"点赞按钮"。可以看到Agent决定点击的按钮跟排名在第一名的故事是对应的。Agent 在这里点对了按钮。
此时浏览器的状态仍然在 Hacker News 的首页。
第四轮

这一轮 Agent 没有继续 Tool_Call,而是选择了 Stop. 并且在 Final Answer 中 Agent 认为自己已经成功点赞。然而浏览器的跳转到了登录界面,说明点赞前需要登录,Agent在撒谎。
这一轮浏览器的状态在登录界面:

我们可以列一个表格总结一下我们目前的情况:
| 观察到的现象 | 证据 | 结论 |
|---|---|---|
| Agent点击了按钮 | browser_click 工具调用的结果 | 已证明 |
| Agent进入登录页 | 浏览器状态的截图 | 已证明 |
| 点赞成功 | 无证据 | 未证明 |
| Agent认为点赞成功 | Final Answer | 已证明 |

Agent 在缺乏成功证据的情况下,推断自己已经完成点赞任务,这是典型的幻觉。
为什么Agent会认为自己完成了任务?
Agent产生幻觉的原因一直是我在工作中最难回答的问题。本质上 LLM 是一个概率统计的产物,LLM 只是选择概率最大的那个答案,但是我们却没有办法知道为什么它选择这个答案。
我认为可能有以下几个原因:
- Agent 将动作完成(Action Completion)误认为目标完成(Goal Completion)。
Tool Result 只告诉了 Agent 动作完成,但没有告诉 Agent 任务完成。
最终我们的目标是需要给故事点赞,但是点击点赞按钮是否就等于完成了我们的目标?我们需要给Agent清晰的定义怎么样算完成了任务。
- Agent 没有验证的意识。
Agent 没有验证过自己处在哪个状态。我们是否给 Agent 提供了充足的手段让它能够验证自己的结果?比如说之前通过调用 browser_get_stories 可以看到每个故事的点赞状态,那么 Agent 可以调用 browser_url 看看自己当前处在哪个页面,如果处在 Hacker News 的首页就可以继续调用 browser_get_stories. 查看自己是否给排名第一的故事点赞了。
- Agent 不理解点赞这个行为。
联想到我们平常使用小红书,微博,朋友圈等等,给一个帖子点赞是表达我们对贴子认同,是有主体的。所以需要先登录再点赞。当然,也存在不需要登录就能点赞的平台。在 Agent 的训练内容里面,我们有大量的静态知识,大量文本。但是对于动态的,像点赞动作这一类的训练,在训练数据中的占比很少。所以 Agent 本身就缺乏这种一步一个动作的知识。
- Agent 不理解 https://news.ycombinator.com/vote?id=48428025&how=up&goto=news 这个链接的意义。
我尝试问了 Deepseek 这个链接的含义:

如果能够知道这个链接的含义,应该可以理解到这是一个中间页,说明点赞的动作还没做完。
如何解决 Agent 的幻觉问题?
继续在 Prompt 里面强调 Agent 要记得登录吗?在这里我还是想引用黄超老师的话:
"如果能用一句Prompt解决问题那是最厉害的。"
我认同这个观点,在 Prompt Engineering 和 Context Engineering 的阶段,我们专注于 Prompt 解决问题。但随着 Agent 的 Context Windows 越来越大,我们开始追求让 Agent 完成长程任务,我们的 Prompt 在浩瀚的 Context 中,Agent 对 Prompt 的注意力会丢失,Prompt 对 Agent 的约束会变弱。
因此,我们尝试在 Harness 层面解决问题。
……
感受
写一篇博客远比看20分钟的视频要难得多。初看 Tejas 对于 Harness 的 Talk,我被20分钟讲清楚 Harness 而震撼。等我自己真正落实到一行一行代码,才感受到 Tejas 为了将自己想表达的观点压缩在 20分钟内讲完需要付出多少努力。其实,现在应该是进行暑期实习的时间,看到朋友圈里的同学们拿到Offer 既会开心但也会焦虑,同辈的压力在高中是成绩,在大学是绩点和实习,到了工作会变成绩效吗?
做好一件事情很难。不论这件事是扫地,做饭还是工作。在本科的时候,我能够沉下心来一天做好一件事情,认真分析实验记录,探究实验过程。随着年龄的增长,这种专注纯粹的感觉似乎在减少。在写博客的时候,我反复问自己这样做是为了什么,是为了凸显自己很懂 Harness 而产生优越感吗?是为了学会Harness,给开源项目做贡献,让别人看到我的价值,找到一个更好的工作吗?
尽管我知道自己在做正确并且热爱的事情,但在这个过程我仍然会感到疲倦想放弃,但最后我还是会重振旗鼓继续写下去。
我认为自己的初心是交流,希望把好的东西分享出来,抛砖引玉,共同进步!
