Agent Harness 是怎样和大模型交流的?

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 我是谁,我的幸运数字是什么。然后他能够基于我的询问做出正确的回答。

Image

API 直接交互

但如果我们抛开 Agent 这个架构,直接通过 API 和 AI 进行对话,会发生什么事情?

直接交互案例

下面这个代码(节选)通过 request 的形式向 API 分别发送了两段对话,内容和使用 Agent 交互的一样。

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 里面那样的呀。

===== 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。

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 来处理已经存在的上下文内容,再继续使用。

===== 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 目录下,它的格式大概是这样的:

{"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:每条事件对应一个 idparentId 对应的是上一条事件的 id

  • roleuser 代表这条消息是用户发送的,assistant 代表该条消息是 AI 回复的内容

从代码来看 Agent 是如何构建消息的

接下来我们进入到 Pi Agent 的代码实现中,看看发送给 API 的消息是由哪些内容构成的。

AgentContext

核心的数据结构是 AgentContext, Agent 发送给 API 的消息内容,就是直接从这个结构体里取的。

它由三部分组成:

  • systemPrompt:系统及项目级提示词,包含了 Pi Agent 项目的提示词,以及工作目录提供的提示词。

  • messages:用户与 AI 之间的聊天记录合集。

  • tools:可提供给 AI 调用的工具信息。

packages/agent/src/agent.ts

/** 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

其中 systemPromptbuildSystemPrompt() 函数中进行构建,具体样式如下:

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

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

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() 函数进行读取。

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 主要包括两类内容:

  1. LLM 通信消息:用户输入、模型回复(包括 toolCall)、工具执行结果(toolResult)等

  2. UI 类型信息(在此不作展开)

以"用户问了一个问题,模型需要调用一次工具,最终给出答案"这个简单的流程作为例子,分析在交互过程中 AgentContext.messages 是如何变化的。整个流程翻译过来就是"提问 → 回复(toolcall) → 返回 toolcall 信息 → 输出结果"。

当用户输入"帮我读取 a.txt 这个文件的内容"作为提问时,输入信息会通过 prompt() 函数进行解析,此时的 message 内容:

messages: 
  [user: "帮我读取 a.txt 这个文件的内容"]

随后间接调用 runAgentLoop() 函数进入循环处理。

/** 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() 函数。

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,并获取它的回复。

// Stream assistant response
const message = await streamAssistantResponse(currentContext, config, signal, emit, streamFunction);
newMessages.push(message);

此时,LLM 回复带有 toolcall 操作的 message,表示"我要调用 read 工具读 a.txt"。

messages: 
[user: "帮我读取 a.txt 这个文件的内容",
assistant: "我要调用 read 工具读 a.txt"]

依次执行 toolcall 操作,并将结果 toolResults 逐项打包进 currentContext.messages 中。

// 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 的内容被更新为:

messages: 
[user: "帮我读取 a.txt 这个文件的内容",
assistant: "我要调用 read 工具读 a.txt",
toolResult: "a.txt 的内容是: hello world"]

最后回到 runLoop() 函数的循环中,再次调用 streamAssistantResponse() 函数 currentContext 发送给 LLM,并获取它的回复。

messages: 
[user: "帮我读取 a.txt 这个文件的内容",
assistant: "我要调用 read 工具读 a.txt",
toolResult: "a.txt 的内容是: hello world",
assistant: "这个文件里写的是 hello world"]

直到处理完所有的 toolcallpending 消息(在 Agent 运行期间用户输入的 prompt)后,跳出 while (hasMoreToolCalls || pendingMessages.length > 0) 这个循环处理,结束此次调用。至此,一个包含 toolcall 的调用流程就完成了。

// 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

/***
* * 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() 函数进行注册。

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):将新添加的工具激活。

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 的博客,刚开始接触与了解这个领域的内容,且这篇文章绝大部分内容都是手搓的,如果有写得不对的地方,欢迎留言指出,也欢迎与我进行讨论。

posted @ 2026-08-27 14:10  ACai_sec  阅读(162)  评论(0)    收藏  举报