ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Code Agent 解剖(01):用户输入一句话,agent 内部发生了什么?

Code Agent 解剖(01):用户输入一句话,agent 内部发生了什么? 框架把问题藏起来了用过 LangChain 或 LlamaIndex 的人都有同感文档说三行代码跑起来一个 agent代码确实跑起来了但出问题时完全不知道往哪调。工具调用失败了上下文截断了模型没按预期停止框架把这些全包掉了你只能猜。MyCodeAgent 是一个没有框架魔法的本地 coding agent大约 14000 行 Python所有核心逻辑都暴露在源码里。本系列用它作为解剖对象逐层看清 agent 是怎么跑起来的。第一篇先建整体地图一句用户输入从键盘到回答经过了哪几道关口。三个阶段一张地图python main.py │ ▼ ┌──────────────────────────────┐ │ 阶段 1CLI 入口 │ app/cli.py │ 解析参数决定运行模式 │ └──────────────┬───────────────┘ │ ▼ ┌──────────────────────────────┐ │ 阶段 2依赖组装 │ app/bootstrap.py │ Config → LLM → 工具 → Agent │ └──────────────┬───────────────┘ │ ▼ ┌──────────────────────────────────────────────────────┐ │ 阶段 3ReAct 主循环 │ runtime/loop.py │ │ │ ┌─────────────────────────────────────────────┐ │ │ │ 用户输入 → 构建 Model View → 调 LLM │ │ │ │ ↓ │ │ │ │ 有 tool_calls→ 执行工具 → 追加观测结果 │ │ │ │ 没有 tool_calls→ 完成门检查 │ │ │ │ ↓ │ │ │ │ 通过 → 输出 失败 → 注入反馈 → 继续循环 │ │ │ └─────────────────────────────────────────────┘ │ └──────────────────────────────────────────────────────┘三个阶段各有一个核心职责CLI 决定怎么跑bootstrap 决定用什么跑loop 决定跑出来什么。阶段 1CLI 入口main.py只有一行# main.pyfromapp.cliimportmainif__name____main__:main()真正的逻辑在app/cli.py的main()函数里。它做两件事判断运行模式然后把控制权交给 bootstrap。# app/cli.py — main() 的核心判断argsparser.parse_args()# -p 参数存在 → 一次性模式跑完即退出适合脚本调用# 没有 -p → 交互模式while True 循环等待用户输入one_shotgetattr(args,print_prompt,None)isnotNoneruntimebuild_runtime(args,agent_class...)两种模式的区别只是外壳一次性模式跑完就raise SystemExit交互模式进入while True循环读用户输入。核心的 agent 逻辑在两种模式下完全一样。交互模式用prompt_toolkit读输入# cli.pysessionPromptSession(historyFileHistory(.chat_history))user_inputsession.prompt(HTML(useruser/user arrow➜/arrow ),styleprompt_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(modelllm.model,providerllm.provider,...)}这是依赖注入的标准做法调用方不知道被注入物的细节只提供一个工厂函数让 bootstrap 在合适的时机调用。阶段 2依赖组装app/bootstrap.py的build_runtime()按固定顺序组装所有依赖顺序不能乱因为后面的步骤依赖前面的结果# app/bootstrap.py — build_runtime() 核心步骤resolved_project_rootresolve_project_root(selected_project_root)# --cwd 或当前目录configConfig.from_env()# 从 .env 加载llmHelloAgentsLLM(modelargs.model,api_keyargs.api_key,...)# 命令行参数优先tool_registryToolRegistry()# 空注册表CodeAgent 内部填充agentCodeAgent(llmllm,tool_registrytool_registry,configconfig,...)命令行参数优先这句话值得展开不同参数的兜底逻辑并不一样。argparse 没有收到某个参数时args.xxx的值是None因为defaultNone。None传进HelloAgentsLLM后每类参数各有不同的处理方式model、timeoutor直接读环境变量# core/llm.pyself.modelmodelorself._get_env(LLM_MODEL_ID)self.timeouttimeoutorint(self._get_env(LLM_TIMEOUT,120))None or xxx在 Python 里走右边传None等于去环境变量找。api_key、provider、base_url专门的 resolve 方法self.providerself._resolve_provider(provider,api_key,base_url)self.api_key,resolved_base_urlself._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_executorToolExecutor(self.tool_registry,...)# ④ 工具编排器只读工具并发写操作串行self.tool_orchestratorToolOrchestrator(self)# ⑤ ReAct 主循环驱动器 ← 真正干活的地方self.runnerRuntimeRunner(self)注意最后一行RuntimeRunner(self)把整个CodeAgent作为参数传进去。Runner 需要访问 agent 上的所有属性llm、tool_registry、history_manager…CodeAgent在这里是一个依赖容器不是业务逻辑的执行者。阶段 3ReAct 主循环用户输入从键盘到RuntimeRunner经过了四层session.prompt() # cli.py — 阻塞读用户输入 → run_interactive_turn() # cli.py — 捕获 CtrlC不让中断泄漏到外层 → 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_steps1):# 1. 构建本轮的 Model View完整历史的有界投影state,tools_schema,messagesself._prepare_step_context(...)# 2. 调 LLM拿回原始响应raw_responsehost.llm.invoke_raw(messages,toolstools_schema)# 3. 从响应里提取 tool_calls 和文字内容tool_callsextract_tool_calls(raw_response)response_textextract_response_content(raw_response)# 4. 有工具调用 → 执行工具 → 把结果追加到历史 → 继续循环iftool_calls:observationshost.tool_orchestrator.run(tool_calls,stepstep,...)# 追加 assistant 消息 tool result 消息到历史continue# 5. 没有工具调用 → 交给完成门判断verdicthost.completion_verifier.evaluate(response_text,...)ifverdict.verdictCompletionGateVerdict.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是frozenTrue的 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 → CodeAgentReAct 主循环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 与技能市场所有内容均经过真实企业级工作流验证。没有噱头只有真正有效的东西。更多实用知识和有趣产品欢迎访问我的个人主页
返回列表