
平时使用 Coding Agent 时,我们只看到两件事:输入一句话,然后屏幕上慢慢出现回复。
但真正让它区别于普通聊天机器人的,并不是“调用了一次大模型”,而是中间那套不断循环的 Agent Loop:整理上下文、请求模型、执行工具、回填结果,再请求模型,直到得到最终答案。
最近我在写一个本地 Python Coding Agent——FirstCoder。下面以“读取 README 并总结”为例,拆开一条用户消息从进入 TUI 到完整回复的全过程。
一、用户按下 Enter 后,消息先去哪里?
Textual TUI 会先读取输入框内容,显示用户消息,然后启动一个异步任务。UI 不直接调用模型,而是把消息交给 Runtime,再由 Runtime 为这一轮创建 AgentLoop。
第一件发生的事也不是网络请求,而是把用户消息写入 append-only 的 Session Log。
也就是说,“读取 README 并总结”会先成为一条 user_message 事件。后续即使程序退出,FirstCoder 也可以根据日志重建这轮会话,而不是只依赖内存里的一份 messages 数组。
二、真正发给模型的,不只有这一句话
AgentLoop 会从 Session Log 重建当前会话,再由 ContextBuilder 投影成模型能理解的 messages。
一次请求大致包含:
{
"model": "当前选择的模型",
"messages": [
{"role": "system", "content": "项目规则、AGENTS.md、权限策略、Skills……"},
{"role": "user", "content": "历史消息或压缩后的摘要"},
{"role": "assistant", "content": "历史回复"},
{"role": "user", "content": "读取 README 并总结"}
],
"tools": [
{"name": "view", "description": "读取文件", "parameters": "JSON Schema"},
{"name": "grep", "description": "搜索代码", "parameters": "JSON Schema"}
],
"tool_choice": "auto",
"stream": true
}
所以模型拿到的不是一个孤零零的问题,而是“系统规则 + 会话历史 + 最新消息 + 可用工具说明”。工具只以 Schema 的形式发给模型,模型知道自己可以调用什么,但真正的工具仍在本地执行。
三、模型第一次返回的,可能不是答案
如果任务只需要语言知识,模型可以直接返回文本,Agent Loop 写入 assistant_message 后结束。
但“读取 README”需要真实文件内容。此时模型第一次返回的通常是一个 tool_call:
{
"id": "call_view",
"name": "view",
"arguments": {
"path": "README.md",
"limit": 200
}
}
在流式模式下,工具参数可能分散在多个 chunk 中到达。FirstCoder 会先把这些片段按 index 累积,只有收到完整 tool_call 并确认参数是合法 JSON 后,才允许进入执行阶段。半截参数不会被猜测或执行。
四、工具不是模型执行的
模型只负责提出“我想调用 view”。AgentLoop 会先把这条 assistant tool_call 写入日志,然后把它交给本地 ToolExecutor。
ToolExecutor 会检查:
1. 工具是否存在;
2. 参数是否合法;
3. 当前权限策略是允许、拒绝,还是需要询问用户;
4. 如果是写文件,是否需要先生成 Diff 供用户审查;
5. 只读工具能否安全地并行执行。
通过检查后,真正读取 README.md 的是本地 Python 工具,而不是大模型。执行结果随后被保存为一条 role=tool 的 tool_result,并且带回原始 tool_call_id。
会话顺序会变成:
user:读取 README 并总结
assistant:调用 view(path="README.md")
tool:返回 README 的真实内容
这个顺序不能乱。模型下一次请求必须看到合法的“assistant tool_call → tool result”配对,否则不少 Provider 会直接拒绝请求。
五、工具结果还要再发给模型一次
拿到文件内容并不代表任务结束。工具只返回事实,不负责组织最终答案。
AgentLoop 会重新构造上下文,再次请求模型。这次 messages 里多了两项:模型刚才提出的 tool_call,以及本地工具返回的 tool_result。
模型现在终于看到了 README 的真实内容,于是可以生成最终总结。如果它发现还需要搜索其他文件,就会再次返回 tool_call,Agent Loop 继续下一轮:
模型 → 工具调用 → 本地执行 → 结果回填 → 再问模型
直到某次模型响应不再包含 tool_calls,才被认为是这一轮的最终回复。
六、为什么回复能一个字一个字显示?
Provider Adapter 会把不同模型厂商的流式协议统一成内部事件:
message_started
text_delta
tool_call_delta
tool_call_completed
message_completed
TUI 收到 text_delta 后立即更新界面,所以用户不用等整段答案生成完。流结束后,完整 ChatResponse 仍会被保存到 Session Log,避免界面显示了一份、会话记录里却是另一份。
七、整个 Agent Loop 可以压缩成这条链路
用户输入
→ 写入 Session Log
→ 重建上下文
→ 拼接 System Prompt、历史消息和 Tools Schema
→ 请求模型
→ 模型直接回答?写入最终回复并结束
→ 模型请求工具?本地检查权限并执行
→ Tool Result 写回上下文
→ 再次请求模型
→ 直到没有新的 Tool Call
→ 流式显示并持久化最终回复
这也是我写 FirstCoder 时最想弄明白的东西:Coding Agent 并不是“LLM 加几个函数”这么简单。真正决定它是否可靠的,是工具调用顺序、权限边界、上下文重建、流式事件和异常恢复能不能组成一个闭环。
FirstCoder 是一个本地 Python Coding Agent。我把 AgentLoop、Providers、Tools、Permissions、Session 和 Context 分成了独立模块,希望它既能运行,也能让想学习 Agent 工程的人顺着源码读明白。
项目地址:https://github.com/KomorGiaoGiao/FirstCoder
欢迎提issue和pr,更欢迎⭐(厚颜无耻)