Agent Harness 是怎样和大模型交流的?
前言
现在的 AI 模型更新速度可谓日新月异,每当新出现了一个新模型时,人们第一反应总是到 Coding Agent 中把 base url 和 API key 配置好,然后猛猛进行测试,最后在社交媒体上锐评一下这个模型是夯还是拉。
那么有个问题来了,既然通过 API 就能直接和大模型进行交互了,那为什么我们总是需要一个 Agent Harness 来"协助"我们使用大模型呢?直接通过 API 聊不行吗?
接下来我们以 Pi Agent 为例,了解一下 Agent Harness 是如何组织消息与大模型交流的。
https://github.com/earendil-works/pi/tree/a470b121bf683b4c2b9fc0b3a7c807de7e0cfe9c
从案例来看 Agent 是如何构建消息的
我们在通过 Agent 与 AI 进行交互时,通常采用的都是"一问一答"的形式。这种形式看起来和我们平常使用的聊天软件过程是一样的,你发一条消息,AI 收到以后回复你一条消息,你根据 AI 的回复再发送你的下一个请求。
下面是一个简单例子,我通过 Agent 告诉 AI 我是谁,我的幸运数字是什么。然后他能够基于我的询问做出正确的回答。
API 直接交互
但如果我们抛开 Agent 这个架构,直接通过 API 和 AI 进行对话,会发生什么事情?
直接交互案例
下面这个代码(节选)通过 request 的形式向 API 分别发送了两段对话,内容和使用 Agent 交互的一样。
Python
DIALOGUE = [
"Please remember this: my name is ACai and my lucky number is 7. I will ask you about it later, so be ready to answer.",
"What is my name and what is my lucky number?",
]
def main():
cfg = json.loads(CONFIG_PATH.read_text("utf-8"))
api_key = cfg["api_key"]
base_url = cfg.get("base_url", "https://api.deepseek.com")
model = cfg.get("model", "deepseek-v4-flash")
ask(DIALOGUE[0], api_key, base_url, model) *# 1st request: ask it to remember*
ask(DIALOGUE[1], api_key, base_url, model) *# 2nd request: ask without history*
我们看到输出的内容出现了很大的差异,尤其是在第二阶段时,通过 Agent 进行交互的第二个问题 AI 能够准确无误地回答出来。而通过 request 直接交互的第二个问题,AI 给出的回复是"I don't know"。
不对啊,你应该回答我"你是 ACai,你的幸运数字是 7" 啊,你应该是像 Agent 里面那样的呀。
Solidity
===== Fist Request =====
User: Please remember this: my name is ACai and my lucky number is 7. I will ask you about it later, so be ready to answer.
Assistant: Got it, ACai! I'll remember your lucky number is 7. Feel free to ask about it later.
===== Second Request =====
User: What is my name and what is my lucky number?
Assistant: I don't know your name or your lucky number---I can't see or remember personal information. If you'd like to share them, I'd be happy to use your name and even help you pick a lucky number! 😊
那为什么 Agent 里面的 AI 是"有记忆"的,而直接通过 API 调用的 AI 是"无记忆"的呢?他不应该和我平常用的聊天软件那样,随时发送消息都能够接着以前的内容聊的吗?
因为 LLM 它本质上只是一个猜字机器,不具备记忆功能。它只是根据你的输入,根据训练时学会的知识,推断出它应该输出什么内容。
那为什么 Agent 能够让 LLM 拥有"记忆",我可以和他围绕一个话题持续交流呢?原理很简单,但是有点违反直觉------ Agent 在向 API 发送的消息内容的时候,每一次都是将之前所有的聊天记录一起打包发过去的。
也就是说,就是每次发消息的时候,Agent 都会把这个聊天窗口内的所有历史聊天记录打包发给 AI,让 AI 根据提供的聊天记录来提供回复,并发送给 Agent。
含历史记录交互案例
那如果我们改动一下询问的格式,看看会有什么不同的效果。首先给每句话添加角色。其次,第一句询问照旧,记录第一次回答的内容,在第二次询问时把历史对话都发送给 AI。
Python
def ask_with_history(*api_key*, *base_url*, *model*):
"""Send the 2nd question WITH the full conversation history."""
messages = [{"role": "user", "content": DIALOGUE[0]}]
*# 1st request: identical to Demo 1; record the assistant's reply*
reply1 = chat(messages, api_key, base_url, model)
*# 2nd request: append the recorded answer + new question, send ALL of it*
messages.append({"role": "assistant", "content": reply1})
messages.append({"role": "user", "content": DIALOGUE[1]})
chat(messages, api_key, base_url, model)
在获取到了之前的聊天记录以后,AI 的回答就变得"有记忆"了,它拥有了上下文的概念了。而这也是导致平常遇到的"maximum context length exceeded"(上下文过长)问题的原因,由于聊天记录随着交互次数的增加而增加,当它的长度大于了模型所能够接受的最大长度时,就会发出这个告警。然后我们就需要通过 compact 或者 clean 来处理已经存在的上下文内容,再继续使用。
Python
===== Fist Request =====
User: Please remember this: my name is ACai and my lucky number is 7. I will ask you about it later, so be ready to answer.
Assistant: Got it! Your name is ACai and your lucky number is 7. I'll remember that. 👍
===== Second Request =====
User: What is my name and what is my lucky number?
Assistant: Your name is **ACai** and your lucky number is **7**. I've got it locked in! 😊
而在 Pi Agent 里面,上下文的记录称为 Session,保存在 .pi/agent/sessions 目录下,它的格式大概是这样的:
JSON
{"type":"session","version":3,"id":"01a032ab-40f9-7bd2-8e97-b8afb94eaf6a","timestamp":"2026-08-24T07:27:59.225Z","cwd":"/Users/acai"}
{"type":"message","id":"764ff2cc","parentId":null,"timestamp":"2026-08-24T07:28:22.821Z","message":{"role":"user","content":[{"type":"text","text":"Please remember this: my name is ACai and my lucky number is 7. I will ask you about it later, so be ready to answer."}],"timestamp":1787556502820}}
{"type":"message","id":"27001a1a","parentId":"764ff2cc","timestamp":"2026-08-24T07:28:23.752Z","message":{"role":"assistant","content":[{"type":"text","text":"Got it --- I'll remember:\n\n- **Name:** ACai\n- **Lucky number:** 7\n\nWhenever you ask, I'll be ready to answer."}],"api":"openai-completions","provider":"deepseek","model":"deepseek-v4-flash","usage":{"input":2010,"output":34,"cacheRead":0,"cacheWrite":0,"reasoning":0,"totalTokens":2044,"cost":{"input":0.0002814,"output":0.00000952,"cacheRead":0,"cacheWrite":0,"total":0.00029092}},"stopReason":"stop","timestamp":1787556502822,"responseId":"ebaa49ba-22ff-427a-835f-d6cc36f0d554","rawStopReason":"stop"}}
{"type":"message","id":"8cf8f658","parentId":"27001a1a","timestamp":"2026-08-24T07:28:33.231Z","message":{"role":"user","content":[{"type":"text","text":"What is my name and what is my lucky number?"}],"timestamp":1787556513231}}
{"type":"message","id":"f053169f","parentId":"8cf8f658","timestamp":"2026-08-24T07:28:34.055Z","message":{"role":"assistant","content":[{"type":"text","text":"Your name is **ACai** and your lucky number is **7**. 🍀"}],"api":"openai-completions","provider":"deepseek","model":"deepseek-v4-flash","usage":{"input":139,"output":18,"cacheRead":1920,"cacheWrite":0,"reasoning":0,"totalTokens":2077,"cost":{"input":0.00001946,"output":0.00000504,"cacheRead":0.000005376,"cacheWrite":0,"total":0.000029876}},"stopReason":"stop","timestamp":1787556513232,"responseId":"44087773-1aa5-486d-b881-4aa15bbe50bc","rawStopReason":"stop"}}
其中各个字段的含义简单介绍一下:
-
type:代表消息类型,message代表发送的消息内容 -
id&parentId:每条事件对应一个id,parentId对应的是上一条事件的id -
role:user代表这条消息是用户发送的,assistant代表该条消息是 AI 回复的内容
从代码来看 Agent 是如何构建消息的
接下来我们进入到 Pi Agent 的代码实现中,看看发送给 API 的消息是由哪些内容构成的。
AgentContext
核心的数据结构是 AgentContext, Agent 发送给 API 的消息内容,就是直接从这个结构体里取的。
它由三部分组成:
-
systemPrompt:系统及项目级提示词,包含了 Pi Agent 项目的提示词,以及工作目录提供的提示词。 -
messages:用户与 AI 之间的聊天记录合集。 -
tools:可提供给 AI 调用的工具信息。
packages/agent/src/agent.ts
C++
*/** Context snapshot passed into the low-level agent loop. */*
export interface AgentContext {
*/** System prompt included with the request. */*
systemPrompt: string;
*/** Transcript visible to the model. */*
messages: AgentMessage[];
*/** Tools available for this run. */*
tools?: AgentTool<any>[];
}
private **createContextSnapshot**(): AgentContext {
return {
systemPrompt: this._state.systemPrompt,
messages: this._state.messages.slice(),
tools: this._state.tools.slice(),
};
}
AgentContext.systemPrompt
其中 systemPrompt 在 buildSystemPrompt() 函数中进行构建,具体样式如下:
Plain
You are an expert coding assistant operating inside pi, ... // 1. Identity Description
Available tools: // 2. Tools List
- read: ...
- bash: ...
Guidelines: // 3. Operating Guidelines
- Be concise in your responses
...
Pi documentation (read only when ...): // 4. pi Document Path Index
<project_context> // 5. AGENTS.md Project Prompt
<project_instructions path=".../AGENTS.md">...</project_instructions>
</project_context>
Current working directory: /path // 6. Workflow Path
如果自定义了 SYSTEM.md,则将 1-4 的内容替换为自定义内容(默认没有 SYSTEM.md)
具体的代码实现如下,其中 contextFiles 代表的就是 AGENT.md 文件列表。
packages/coding-agent/src/core/system-prompt.tsL121-161
TypeScript
let prompt = `You are an expert coding assistant operating inside pi, a coding agent harness. You help users by reading files, executing commands, editing code, and writing new files.
Available tools:
${toolsList}
In addition to the tools above, you may have access to other custom tools depending on the project.
Guidelines:
${guidelines}
Pi documentation (read only when the user asks about pi itself, its SDK, extensions, themes, skills, or TUI):
- Main documentation: ${readmePath}
- Additional docs: ${docsPath}
- Examples: ${examplesPath} (extensions, custom tools, SDK)
- When reading pi docs or examples, resolve docs/... under Additional docs and examples/... under Examples, not the current working directory
- When asked about: extensions (docs/extensions.md, examples/extensions/), themes (docs/themes.md), skills (docs/skills.md), prompt templates (docs/prompt-templates.md), TUI components (docs/tui.md), keybindings (docs/keybindings.md), SDK integrations (docs/sdk.md), custom providers (docs/custom-provider.md), adding models (docs/models.md), pi packages (docs/packages.md), environment variables (docs/environment-variables.md)
- When working on pi topics, read the docs and examples, and follow .md cross-references before implementing
- Always read pi .md files completely and follow links to related docs (e.g., tui.md for TUI API details)`;
if (appendSection) {
prompt += appendSection;
}
*// Append project context files*
if (contextFiles.length > 0) {
prompt += "\n\n<project_context>\n\n";
prompt += "Project-specific instructions and guidelines:\n\n";
for (const { path: filePath, content } of contextFiles) {
prompt += `<project_instructions path="${filePath}">\n${content}\n</project_instructions>\n\n`;
}
prompt += "</project_context>\n";
}
*// Append skills section (only if read tool is available)*
if (hasRead && skills.length > 0) {
prompt += formatSkillsForPrompt(skills);
}
prompt += `\nCurrent working directory: ${promptCwd}`;
return prompt;
其中是否存在自定义的 SYSTEM.md 配置是通过 discoverSystemPromptFile() 函数读取,先找项目里的 .pi/SYSTEM.md,如果没有就找全局 ~/.pi 下的 SYSTEM.md,如果都没有,那就返回 undefined。
TypeScript
private **discoverSystemPromptFile**(): string | undefined {
const projectPath = join(this.cwd, CONFIG_DIR_NAME, "SYSTEM.md");
if (this.settingsManager.isProjectTrusted() && existsSync(projectPath)) {
return projectPath;
}
const globalPath = join(this.agentDir, "SYSTEM.md");
if (existsSync(globalPath)) {
return globalPath;
}
return undefined;
}
而 AGENT.md 等一类的背景信息则是通过 loadContextFileFromDir() 函数进行读取。
TypeScript
function **loadContextFileFromDir**(*dir*: string): { path: string; content: string } | null {
const candidates = ["AGENTS.override.md", "AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD"];
for (const filename of candidates) {
const filePath = join(*dir*, filename);
if (existsSync(filePath)) {
// return `path` and `content`
}
}
return null;
}
AgentContext.messages
AgentContext.messages 主要包括两类内容:
-
LLM 通信消息:用户输入、模型回复(包括
toolCall)、工具执行结果(toolResult)等 -
UI 类型信息(在此不作展开)
以"用户问了一个问题,模型需要调用一次工具,最终给出答案"这个简单的流程作为例子,分析在交互过程中 AgentContext.messages 是如何变化的。整个流程翻译过来就是"提问 → 回复(toolcall) → 返回 toolcall 信息 → 输出结果"。
当用户输入"帮我读取 a.txt 这个文件的内容"作为提问时,输入信息会通过 prompt() 函数进行解析,此时的 message 内容:
Plain
messages:
[user: "帮我读取 a.txt 这个文件的内容"]
随后间接调用 runAgentLoop() 函数进入循环处理。
TypeScript
*/** Start a new prompt from text, a single message, or a batch of messages. */*
async **prompt**(*message*: AgentMessage | AgentMessage[]): Promise<void>;
async **prompt**(*input*: string, *images*?: ImageContent[]): Promise<void>;
async **prompt**(*input*: string | AgentMessage | AgentMessage[], *images*?: ImageContent[]): Promise<void> {
if (this.activeRun) {
throw new Error(
"Agent is already processing a prompt. Use steer() or followUp() to queue messages, or wait for completion.",
);
}
const messages = this.normalizePromptInput(*input*, *images*);
await this.runPromptMessages(messages);
}
接下来用户的 prompt 将会被添加到 AgentContext.messages 的末端,调用 runLoop() 函数。
TypeScript
export async function **runAgentLoop**(
*prompts*: AgentMessage[],
*context*: AgentContext,
*config*: AgentLoopConfig,
**emit**: AgentEventSink,
*signal*: AbortSignal | undefined,
**streamFn**: StreamFn,
): Promise<AgentMessage[]> {
const newMessages: AgentMessage[] = [...*prompts*];
const currentContext: AgentContext = {
...*context*,
messages: [...*context*.messages, ...*prompts*],
};
...
}
在 runLoop() 函数中,通过 streamAssistantResponse() 函数把包含用户输入的 currentContext 发送给 LLM,并获取它的回复。
TypeScript
*// Stream assistant response*
const message = await streamAssistantResponse(currentContext, config, *signal*, emit, streamFunction);
*newMessages*.push(message);
此时,LLM 回复带有 toolcall 操作的 message,表示"我要调用 read 工具读 a.txt"。
Plain
messages:
[user: "帮我读取 a.txt 这个文件的内容",
assistant: "我要调用 read 工具读 a.txt"]
依次执行 toolcall 操作,并将结果 toolResults 逐项打包进 currentContext.messages 中。
JavaScript
*// Check for tool calls*
const toolCalls = message.content.filter((*c*) => *c*.type === "toolCall");
const toolResults: ToolResultMessage[] = [];
hasMoreToolCalls = false;
if (toolCalls.length > 0) {
*// A "length" stop means the output was cut off by the token limit, so*
*// every tool call in the message may carry truncated arguments. Fail*
*// them all instead of executing potentially borked calls.*
const executedToolBatch =
message.stopReason === "length"
? await failToolCallsFromTruncatedMessage(toolCalls, emit)
: await executeToolCalls(currentContext, message, config, *signal*, emit);
toolResults.push(...executedToolBatch.messages);
hasMoreToolCalls = !executedToolBatch.terminate;
for (const result of toolResults) {
currentContext.messages.push(result);
*newMessages*.push(result);
}
}
此时 currentContext.messages 的内容被更新为:
Plain
messages:
[user: "帮我读取 a.txt 这个文件的内容",
assistant: "我要调用 read 工具读 a.txt",
toolResult: "a.txt 的内容是: hello world"]
最后回到 runLoop() 函数的循环中,再次调用 streamAssistantResponse() 函数 currentContext 发送给 LLM,并获取它的回复。
Plain
messages:
[user: "帮我读取 a.txt 这个文件的内容",
assistant: "我要调用 read 工具读 a.txt",
toolResult: "a.txt 的内容是: hello world",
assistant: "这个文件里写的是 hello world"]
直到处理完所有的 toolcall 和 pending 消息(在 Agent 运行期间用户输入的 prompt)后,跳出 while (hasMoreToolCalls || pendingMessages.length > 0) 这个循环处理,结束此次调用。至此,一个包含 toolcall 的调用流程就完成了。
TypeScript
*// Outer loop: continues when queued follow-up messages arrive after agent would stop*
while (true) {
let hasMoreToolCalls = true;
*// Inner loop: process tool calls and steering messages*
while (hasMoreToolCalls || pendingMessages.length > 0) {
// Handle message and toolcall
}
*// Agent would stop here. Check for follow-up messages.*
const followUpMessages = (await config.getFollowUpMessages?.()) || [];
if (followUpMessages.length > 0) {
*// Set as pending so inner loop processes them*
pendingMessages = followUpMessages;
continue;
}
*// No more messages, exit*
break;
}
AgentContext.tools
最后就是 tools 参数,它的值通过 setActiveToolsByName() 函数进行设置,并且在 createContextSnapshot() 函数中被赋值到 AgentContext.tools。
根据它的注释我们可以得知,这个函数只记录"已注册"的 tool,未注册的 tool 将会被无视处理。
packages/coding-agent/src/core/agent-session.ts
TypeScript
*/***
* * Set active tools by name.*
* * Only tools in the registry can be enabled. Unknown tool names are ignored.*
* * Also rebuilds the system prompt to reflect the new tool set.*
* * Changes take effect on the next agent turn.*
* */*
**setActiveToolsByName**(*toolNames*: string[]): void {
const tools: AgentTool[] = [];
const validToolNames: string[] = [];
for (const name of *toolNames*) {
const tool = this._toolRegistry.get(name);
if (tool) {
tools.push(tool);
validToolNames.push(name);
}
}
this.agent.state.tools = tools;
*// Rebuild base system prompt with new tool set*
this._baseSystemPrompt = this._rebuildSystemPrompt(validToolNames);
this.agent.state.systemPrompt = this._systemPromptOverride ?? this._baseSystemPrompt;
}
在 _buildRuntime() 函数中,把 ["read", "bash", "edit", "write"] 这四个工具作为默认的 tools,通过 _refreshToolRegistry() 函数进行注册。
JavaScript
const defaultActiveToolNames = this._baseToolsOverride
? Object.keys(this._baseToolsOverride)
: ["read", "bash", "edit", "write"];
const baseActiveToolNames = *options*.activeToolNames ?? defaultActiveToolNames;
this._refreshToolRegistry({
activeToolNames: baseActiveToolNames,
includeAllExtensionTools: *options*.includeAllExtensionTools,
});
_refreshToolRegistry() 函数的功能正是根据设置的参数选择将哪些 tools 进行注册并提供给 Agent 使用
-
if (allowedToolNames):如果存在允许名单,则将名单里的注册工具全部激活; -
else if (options?.includeAllExtensionTools):将所有扩展工具激活; -
else if (!options?.activeToolNames):将新添加的工具激活。
C++
if (allowedToolNames) {
for (const toolName of this._toolRegistry.keys()) {
if (allowedToolNames.has(toolName)) {
nextActiveToolNames.push(toolName);
}
}
} else if (*options*?.includeAllExtensionTools) {
for (const tool of wrappedExtensionTools) {
nextActiveToolNames.push(tool.name);
}
} else if (!*options*?.activeToolNames) {
for (const toolName of this._toolRegistry.keys()) {
if (!previousRegistryNames.has(toolName)) {
nextActiveToolNames.push(toolName);
}
}
}
后记
这是我第一次写关于 AI Agent 的博客,刚开始接触与了解这个领域的内容,且这篇文章绝大部分内容都是手搓的,如果有写得不对的地方,欢迎留言指出,也欢迎与我进行讨论。