Code Agent 解剖(01):用户输入一句话,agent 内部发生了什么?
框架把问题藏起来了
用过 LangChain 或 LlamaIndex 的人都有同感:文档说三行代码跑起来一个 agent,代码确实跑起来了,但出问题时完全不知道往哪调。工具调用失败了?上下文截断了?模型没按预期停止?框架把这些全包掉了,你只能猜。
MyCodeAgent 是一个没有框架魔法的本地 coding agent,大约 14000 行 Python,所有核心逻辑都暴露在源码里。本系列用它作为解剖对象,逐层看清 agent 是怎么跑起来的。
第一篇先建整体地图:一句用户输入,从键盘到回答,经过了哪几道关口。
三个阶段,一张地图
python main.py │ ▼ ┌──────────────────────────────┐ │ 阶段 1:CLI 入口 │ app/cli.py │ 解析参数,决定运行模式 │ └──────────────┬───────────────┘ │ ▼ ┌──────────────────────────────┐ │ 阶段 2:依赖组装 │ app/bootstrap.py │ Config → LLM → 工具 → Agent │ └──────────────┬───────────────┘ │ ▼ ┌──────────────────────────────────────────────────────┐ │ 阶段 3:ReAct 主循环 │ runtime/loop.py │ │ │ ┌─────────────────────────────────────────────┐ │ │ │ 用户输入 → 构建 Model View → 调 LLM │ │ │ │ ↓ │ │ │ │ 有 tool_calls?→ 执行工具 → 追加观测结果 │ │ │ │ 没有 tool_calls?→ 完成门检查 │ │ │ │ ↓ │ │ │ │ 通过 → 输出 失败 → 注入反馈 → 继续循环 │ │ │ └─────────────────────────────────────────────┘ │ └──────────────────────────────────────────────────────┘三个阶段各有一个核心职责:CLI 决定"怎么跑",bootstrap 决定"用什么跑",loop 决定"跑出来什么"。
阶段 1:CLI 入口
main.py只有一行:
# main.pyfromapp.cliimportmainif__name__=="__main__":main()真正的逻辑在app/cli.py的main()函数里。它做两件事:判断运行模式,然后把控制权交给 bootstrap。
# app/cli.py — main() 的核心判断args=parser.parse_args()# -p 参数存在 → 一次性模式(跑完即退出,适合脚本调用)# 没有 -p → 交互模式(while True 循环,等待用户输入)one_shot=getattr(args,"print_prompt",None)isnotNoneruntime=build_runtime(args,agent_class=...)两种模式的区别只是外壳:一次性模式跑完就raise SystemExit,交互模式进入while True循环读用户输入。核心的 agent 逻辑在两种模式下完全一样。
交互模式用prompt_toolkit读输入:
# cli.pysession=PromptSession(history=FileHistory(".chat_history"))user_input=session.prompt(HTML("<user>user</user> <arrow>➜</arrow> "),style=prompt_style,).strip()session.prompt()是阻塞调用,底层把终端切换到 raw mode(逐字符读,不等回车),渲染彩色提示符,并处理光标移动、退格、上下键翻历史。按回车后返回完整字符串,恢复终端正常模式。FileHistory把每次输入持久化到.chat_history文件,重启后按上键仍能翻出历史记录。
这个项目同时用了prompt_toolkit和rich两个库,分工明确:prompt_toolkit负责输入,rich负责输出(Panel、Markdown 渲染、spinner)。这是 Python CLI 工具的常见组合,Claude Code 本身的交互界面也是这个技术栈。
交互模式有一个值得注意的细节——agent_kwargs_factory:
# 只在交互模式下才需要 EnhancedUI# 但 EnhancedUI 依赖 llm.model/llm.provider,而 llm 对象在 bootstrap 里才创建# 解决方法:把"创建 UI"打包成 lambda,等 bootstrap 把 llm 准备好后再调用runtime_kwargs["agent_kwargs_factory"]=lambdaconfig,llm,project_root:{"ui":EnhancedUI(model=llm.model,provider=llm.provider,...)}这是依赖注入的标准做法:调用方不知道被注入物的细节,只提供一个工厂函数,让 bootstrap 在合适的时机调用。
阶段 2:依赖组装
app/bootstrap.py的build_runtime()按固定顺序组装所有依赖,顺序不能乱,因为后面的步骤依赖前面的结果:
# app/bootstrap.py — build_runtime() 核心步骤resolved_project_root=resolve_project_root(selected_project_root)# --cwd 或当前目录config=Config.from_env()# 从 .env 加载llm=HelloAgentsLLM(model=args.model,api_key=args.api_key,...)# 命令行参数优先tool_registry=ToolRegistry()# 空注册表,CodeAgent 内部填充agent=CodeAgent(llm=llm,tool_registry=tool_registry,config=config,...)"命令行参数优先"这句话值得展开,不同参数的兜底逻辑并不一样。
argparse 没有收到某个参数时,args.xxx的值是None(因为default=None)。None传进HelloAgentsLLM后,每类参数各有不同的处理方式:
model、timeout:or直接读环境变量
# core/llm.pyself.model=modelorself._get_env("LLM_MODEL_ID")self.timeout=timeoutorint(self._get_env("LLM_TIMEOUT","120"))None or xxx在 Python 里走右边,传None等于"去环境变量找"。
api_key、provider、base_url:专门的 resolve 方法
self.provider=self._resolve_provider(provider,api_key,base_url)self.api_key,resolved_base_url=self._resolve_credentials(api_key,base_url)这三个涉及多 provider 自动探测和 profile 表查找,逻辑更复杂,单独抽了方法,最终也是None→ 查环境变量 → 查PROVIDER_PROFILES默认表。
temperature:在 bootstrap 层提前决定好
# bootstrap.pytemperature=(getattr(args,"temperature",None)ifgetattr(args,"temperature",None)isnotNoneelseconfig.temperature# config 已经从 .env 读好了),temperature的类型签名是float,不是Optional[float]。如果把None传进去,后续float(None)会抛异常。另外它不从环境变量读,来源只有命令行和config,所以在 bootstrap 层就必须决定好传哪个值。
三种模式汇总:
model / timeout → None 传入,LLM 内部 or 读环境变量 api_key / provider → None 传入,LLM 内部 resolve 方法处理 temperature → bootstrap 层提前用三元表达式决定好,不传 NoneCodeAgent拿到这些依赖后,在_initialize_runtime_components()里继续把内部子组件装配起来:
# runtime/host.py — CodeAgent._initialize_runtime_components()# ① 历史管理 + 上下文引擎(给模型看多少历史)build_runtime_context(self)# ② 持久化(trace 日志 + transcript 崩溃恢复)build_runtime_persistence(self,...)# ③ 工具执行器(权限检查 + 实际调用)self.tool_executor=ToolExecutor(self.tool_registry,...)# ④ 工具编排器(只读工具并发,写操作串行)self.tool_orchestrator=ToolOrchestrator(self)# ⑤ ReAct 主循环驱动器 ← 真正干活的地方self.runner=RuntimeRunner(self)注意最后一行:RuntimeRunner(self)把整个CodeAgent作为参数传进去。Runner 需要访问 agent 上的所有属性(llm、tool_registry、history_manager…),CodeAgent在这里是一个依赖容器,不是业务逻辑的执行者。
阶段 3:ReAct 主循环
用户输入从键盘到RuntimeRunner经过了四层:
session.prompt() # cli.py — 阻塞读用户输入 → run_interactive_turn() # cli.py — 捕获 Ctrl+C,不让中断泄漏到外层 → RichConsoleCodeAgent.run() # cli.py — 控制 UI spinner 的开关 → CodeAgent.run() # host.py — 只有一行,转发给 runner → RuntimeRunner.run() # loop.py — 真正开始 ReAct 循环RichConsoleCodeAgent是CodeAgent的子类,只在交互模式下使用,重写了run()和_execute_tool()来插入 spinner、工具调用树等 UI 渲染逻辑。一次性模式(-p参数)直接用裸CodeAgent,没有这层开销。这种设计让CodeAgent本身保持干净,不耦合任何 UI 代码。
CodeAgent.run()本身只有一行:
# runtime/host.pydefrun(self,input_text,**kwargs):returnself.runner.run(input_text,**kwargs)真正的逻辑全在RuntimeRunner里,CodeAgent只是依赖容器,不执行业务逻辑。
RuntimeRunner.run()在进入主循环之前,先执行_prepare_run()——完成输入预处理(@file引用展开)、刷新 Skills 提示词、初始化 trace 日志、把用户消息写入历史。用户输入在这步写入历史之后,才作为pending_input传给主循环。这部分细节在第 02 篇展开。
然后进入_react_loop():
# runtime/loop.py — _react_loop() 简化版forstepinrange(1,host.max_steps+1):# 1. 构建本轮的 Model View(完整历史的有界投影)state,tools_schema,messages=self._prepare_step_context(...)# 2. 调 LLM,拿回原始响应raw_response=host.llm.invoke_raw(messages,tools=tools_schema)# 3. 从响应里提取 tool_calls 和文字内容tool_calls=extract_tool_calls(raw_response)response_text=extract_response_content(raw_response)# 4. 有工具调用 → 执行工具 → 把结果追加到历史 → 继续循环iftool_calls:observations=host.tool_orchestrator.run(tool_calls,step=step,...)# 追加 assistant 消息 + tool result 消息到历史continue# 5. 没有工具调用 → 交给完成门判断verdict=host.completion_verifier.evaluate(response_text,...)ifverdict.verdict==CompletionGateVerdict.PASS:returnresponse_text# ← 正常出口# 6. 完成门拦截 → 把反馈注入历史 → 让模型再跑一轮self._append_user_message(verdict.blocking_feedback,...)continueReAct 是"Reasoning + Acting"的缩写,对应 loop 里的两个分支:有tool_calls是 Acting(执行动作),没有tool_calls是 Reasoning(输出思考/回答)。
几个容易被忽视的设计决策:
Model View 不等于历史:每轮传给 LLM 的不是全量
history_manager里的消息,而是经过build_model_view()投影出来的有界子集。历史永远完整,给模型看的是经过 token 预算控制的视图。完成门有反馈回路:loop 不是"没有 tool_calls 就退出",而是先过完成门。门拦下来时,会把"todo 列表还有未完成项"这类信息注入成 user 消息,让模型重新思考,最多重试 2 次。
状态机是不可变的:
LoopState是frozen=True的 dataclass,每次状态转移都调.next()返回新对象。任何时刻的状态都是独立的快照,方便 trace 和崩溃恢复。
三层职责划分
cli.py 决定怎么跑(交互 vs 一次性,UI 层) bootstrap.py 决定用什么跑(依赖组装,工厂层) loop.py 决定跑出来什么(ReAct 逻辑,执行层)这三层之间的边界非常清晰:cli 不知道 LLM 怎么调用,bootstrap 不知道循环怎么跑,loop 不知道 UI 怎么渲染。每层只做自己的事。
后续几篇会逐层深入:LLM 接口层怎么统一对接多个 provider,工具协议是怎么设计的,ReAct 循环里的完成门和状态机具体是怎么工作的,上下文压缩是怎么触发的。这篇建立的这张地图,就是定位这些内容的坐标系。
小结
| 阶段 | 核心文件 | 做了什么 |
|---|---|---|
| CLI 入口 | app/cli.py | 解析参数,决定交互/一次性模式,把依赖组装委托给 bootstrap |
| 依赖组装 | app/bootstrap.py | 按序创建 Config → LLM → ToolRegistry → CodeAgent |
| ReAct 主循环 | runtime/loop.py | 构建 Model View → 调 LLM → 执行工具 → 完成门 → 输出 |
关于本系列的源码
本系列所有分析均基于开源项目 MyCodeAgent。
源码里已经按照本系列文章的讲解顺序,在关键位置加入了配套注释——读文章时可以对照代码,也可以直接克隆下来自己跑、改、扩展,基于它开发你自己的 agent。
gitclone https://github.com/chendongqi/MyCodeAgentcdMyCodeAgentcp.env.example .env# 填入你的 LLM API keyuvsyncuv run python main.py欢迎访问 PrimeSkills —— 一个精心策划的 AI Agent 与技能市场,所有内容均经过真实企业级工作流验证。没有噱头,只有真正有效的东西。
更多实用知识和有趣产品,欢迎访问我的个人主页
