从零构建AI编码助手:FastAPI与LangGraph实战

发布时间:2026/11/4 4:52:43
从零构建AI编码助手:FastAPI与LangGraph实战

1. 为什么我要从零造一个编码助手

市面上能写代码的 AI 工具已经多到用不过来了,从 IDE 插件到命令行工具,从单文件补全到整仓库重构,几乎每个方向都有人在做。但我自己用下来,始终觉得缺了点什么。大多数工具停留在“你问我答”的阶段:我贴一段报错,它给我一段解释;我描述一个需求,它吐出一段代码。可真正写代码的时候,最耗时间的往往不是“写”这个动作本身,而是写之前的定位、写之后的验证、以及中间反复的调试和修改。换句话说,问答解决的是信息差,任务执行解决的才是工作量。

这就是我做羲和(XiheAgent)的出发点。它是一个 AI 编码助手,但我不想让它只做一个“会聊天的代码百科”。我希望它能接收一个相对完整的任务描述,比如“帮我把这个 FastAPI 项目里的用户鉴权从 Session 改成 JWT”,然后自己去读代码、定位文件、规划步骤、逐个修改、跑测试、根据报错再调整,最后把改动结果汇报给我。整个过程里,我更像是一个提需求的人,而不是一个逐行指挥的操作员。

这个定位决定了它在技术选型上不能只是一个简单的 LLM 调用封装。它需要一套任务编排机制来管理多步骤流程,需要一套工具调用体系来读写文件、执行命令,还需要一个稳定的后端服务来承载接口和状态。我最终选了 FastAPI 做服务层,用 LangGraph 做任务编排,Python 作为主语言。这套组合不是拍脑袋定的,后面我会详细讲为什么是它们,以及 LangGraph 和 LangChain 到底差在哪。

这篇文章适合两类人看。一类是想了解 AI 编码助手内部到底怎么运转的开发者,我会把架构、状态机、工具设计这些拆开讲;另一类是正在用 FastAPI 和 LangGraph 做类似 Agent 项目的同行,我会把踩过的坑、参数怎么调、目录怎么组织这些实操细节都摊开说。代码不会全贴,但关键逻辑和配置我会给到能直接抄的程度。

2. 整体架构设计与技术选型拆解

2.1 为什么是 FastAPI 而不是 Flask 或 Django

做 Agent 服务,接口层其实不复杂,无非是提交任务、查询状态、获取结果这几类。但 Agent 有个特点:任务执行时间长且状态多变。一个编码任务可能跑几十秒甚至几分钟,中间要经历规划、执行、验证多个阶段,前端需要能实时知道“现在到哪一步了”。这就要求后端天然支持异步和流式输出。

FastAPI 在这两点上是碾压级的。它基于 Starlette,原生 async,配合StreamingResponse或者 WebSocket 做状态推送非常顺手。Flask 是同步的,虽然也能用 gevent 之类的方案,但那是打补丁,不是原生。Django 太重了,ORM、模板、admin 这一整套对一个 Agent 服务来说是负担,我只需要一个轻量的 API 层。

还有一个很实际的原因:FastAPI 的 Pydantic 集成。Agent 的输入输出都是结构化的,任务描述、工具调用参数、执行结果,这些用 Pydantic 模型定义之后,校验和序列化都省心了。LangGraph 的状态定义本身也大量依赖类型注解,两者配合起来很自然。

2.2 LangGraph 和 LangChain 的区别,以及我为什么选前者

这是被问得最多的问题,我干脆在这里说清楚。LangChain 的核心抽象是Chain,也就是把一系列操作串成一条线,A 的输出喂给 B,B 的输出喂给 C。它适合线性流程,比如“检索文档 → 拼接 prompt → 调用模型 → 解析输出”。但编码任务不是线性的,它需要循环和分支:改完代码要跑测试,测试过了就结束,没过就回到修改步骤,而且可能来回好几轮。这种带环的流程,用 Chain 表达起来很别扭。

LangGraph 把流程建模成图(Graph),节点是操作,边是流转条件。关键是它支持条件边和循环,这正是 Agent 需要的。你可以定义一个“执行”节点和一个“验证”节点,验证节点根据结果决定是回到执行节点还是走向结束节点。这就是所谓的 planning 模式,也是 LangGraph 最核心的价值。

我用一个生活化的类比:LangChain 像流水线,产品从一头进从另一头出,中间工序固定;LangGraph 像交通路网,有路口有红绿灯,车可以根据路况选择走哪条路,甚至绕回来重新走。编码任务显然是后者。

至于 LangGraph 和 LangChain 的关系,不是替代而是互补。LangGraph 里照样可以用 LangChain 的组件,比如 prompt 模板、输出解析器、模型封装。我项目里就是两者混用,编排用 LangGraph,具体节点内部的模型调用用 LangChain 的封装,省得自己写。

2.3 羲和的分层结构

整个项目我分成四层,职责清晰,方便单独测试和替换。

层级职责关键技术
接口层接收任务、推送状态、返回结果FastAPI、WebSocket
编排层任务规划、步骤调度、循环控制LangGraph
能力层读写文件、执行命令、检索代码自定义 Tool
模型层LLM 调用、prompt 管理LangChain、模型 API

这么分的好处是,编排层不关心工具怎么实现,能力层不关心任务怎么规划。我想换一个模型,只动模型层;想加一个新工具,只动能力层。各层之间通过明确定义的状态对象通信,这个状态对象是整个系统的“血液”。

2.4 状态对象的设计

LangGraph 的核心是状态在节点间流转。我定义的状态大概长这样:

from typing import TypedDict, Annotated, List from langgraph.graph.message import add_messages class AgentState(TypedDict): task: str # 原始任务描述 plan: List[str] # 规划出的步骤列表 current_step: int # 当前执行到第几步 messages: Annotated[list, add_messages] # 对话与工具调用记录 files_changed: List[str] # 已修改的文件 test_result: str # 最近一次测试结果 retry_count: int # 当前步骤重试次数 status: str # running / done / failed

这里有个细节值得说:messages用了Annotated加add_messages,这是 LangGraph 的机制,表示这个字段是追加而不是覆盖。工具调用的历史需要累积,不能每次覆盖,否则模型就丢失上下文了。而retry_count是覆盖式的,每次进验证节点重新赋值。搞混这两种语义,状态就会乱。

3. 核心细节解析与实操要点

3.1 任务规划节点怎么写

规划节点是整个流程的起点,它的任务是把一句自然语言描述拆成可执行的步骤列表。我用的 prompt 大致是这样的思路:告诉模型它是一个编码任务规划器,输出必须是 JSON 数组,每个元素是一个原子步骤,步骤要具体到“改哪个文件的哪个函数”这个粒度。

这里有个坑我踩过:一开始我让模型自由发挥,结果它规划出来的步骤粒度忽大忽小,有的步骤是“重构整个模块”,有的是“把第 10 行的变量名改掉”。粒度不统一,后面的执行节点就没法稳定处理。后来我在 prompt 里加了约束,要求每个步骤必须能在一次工具调用序列内完成,并且明确禁止“重构”“优化”这类模糊动词。改完之后规划质量稳定多了。

规划节点的输出解析我用 Pydantic 做校验,如果模型返回的不是合法 JSON 或者字段缺失,就触发一次重试。这个重试逻辑不能省,模型偶尔抽风是常态。

3.2 工具调用的设计原则

能力层的工具我设计了四个核心的:读文件、写文件、列目录、执行命令。看起来简单,但每个都有讲究。

读文件工具要支持按行范围读取,不能一次读整个大文件,否则 token 直接爆掉。我设了默认最多读 200 行,超出部分让模型自己决定要不要继续读。写文件工具要支持精确替换而不是整文件覆盖,因为整文件覆盖风险太大,模型只要漏了一行就可能把代码写坏。我实现的是一个基于字符串匹配的替换工具,要求模型提供 old_string 和 new_string,只有 old_string 在文件里唯一匹配时才执行替换。

执行命令工具是最危险的,必须做白名单和超时控制。我只允许跑测试命令、lint 命令这类安全的,禁止任何删除、网络相关的操作。超时设 60 秒,超时直接 kill。这个工具我单独做了日志,每次执行都记录命令和输出,方便事后审计。

提示:工具的描述(description)写得越清楚,模型调用得越准。我一开始工具描述写得很简略,模型经常传错参数。后来把每个参数的类型、含义、示例都写进描述里,调用成功率明显上升。这不是玄学,模型就是靠描述来理解工具能力的。

3.3 循环控制与退出条件

LangGraph 的循环如果没设计好退出条件,会无限打转。我设了三重保险:单步骤重试上限 3 次,总步骤数上限 20 步,整体超时 10 分钟。任何一个触发就强制结束并返回当前状态。

重试上限的逻辑是这样的:执行节点改完代码进验证节点,验证节点跑测试,如果失败就retry_count += 1然后回到执行节点。当retry_count >= 3,条件边直接指向失败结束节点。这个阈值我调过,2 次太少,模型还没来得及修正就放弃了;5 次太多,有时候模型陷入死循环反复改同一个地方。3 次是个比较平衡的值。

3.4 FastAPI 项目目录结构

目录结构这块我参考了不少 FastAPI 项目实战的经验,最终定成这样:

xihe/ ├── app/ │ ├── main.py # 应用入口 │ ├── api/ │ │ ├── routes.py # 路由定义 │ │ └── schemas.py # Pydantic 模型 │ ├── agent/ │ │ ├── graph.py # LangGraph 图定义 │ │ ├── nodes.py # 各节点实现 │ │ ├── state.py # 状态定义 │ │ └── tools.py # 工具实现 │ ├── core/ │ │ ├── config.py # 配置管理 │ │ └── llm.py # 模型封装 │ └── services/ │ └── task_manager.py # 任务生命周期管理 ├── tests/ └── requirements.txt

把 agent 相关的东西全部收在agent/目录下,和 API 层解耦。这样我可以在测试里直接 import graph 跑任务,不用起 HTTP 服务。services/task_manager.py负责维护任务 ID 到状态的映射,因为 LangGraph 跑起来之后状态在内存里,前端查询需要有个地方存。

4. 实操过程与核心环节实现

4.1 环境搭建与依赖安装

先把环境弄干净。我用的是 Python 3.11,低于 3.10 的话 LangGraph 有些类型特性用不了。

python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate pip install fastapi uvicorn langgraph langchain langchain-openai pydantic python-dotenv

依赖不多,核心就这几个。langchain-openai是模型接入层,如果你用别的模型,换成对应的包就行。配置我放在.env里,用python-dotenv加载,API key 这种东西绝对不能硬编码进代码。

4.2 定义图结构

图的定义是整个项目的骨架。我用一个简化的版本说明:

from langgraph.graph import StateGraph, END from app.agent.state import AgentState from app.agent import nodes def build_graph(): workflow = StateGraph(AgentState) workflow.add_node("plan", nodes.plan_node) workflow.add_node("execute", nodes.execute_node) workflow.add_node("verify", nodes.verify_node) workflow.add_node("finish", nodes.finish_node) workflow.set_entry_point("plan") workflow.add_edge("plan", "execute") workflow.add_edge("execute", "verify") workflow.add_conditional_edges( "verify", nodes.should_continue, { "retry": "execute", "next": "execute", "done": "finish", "fail": "finish", } ) workflow.add_edge("finish", END) return workflow.compile()

should_continue这个函数是循环控制的核心,它读取状态里的retry_count、current_step、test_result,返回下一步该走哪个分支。这个函数必须是纯函数,不能有副作用,否则图的行为会不可预测。

4.3 执行节点的实现细节

执行节点是干活最多的地方。它拿到当前步骤的描述,构造一个带工具绑定的模型调用,让模型决定调用哪个工具、传什么参数。这里用的是 LangChain 的 tool binding 机制:

from langchain_openai import ChatOpenAI from app.agent.tools import read_file, write_file, run_command llm = ChatOpenAI(model="gpt-4", temperature=0) llm_with_tools = llm.bind_tools([read_file, write_file, run_command]) def execute_node(state: AgentState): step = state["plan"][state["current_step"]] response = llm_with_tools.invoke( build_execute_prompt(step, state["messages"]) ) return {"messages": [response]}

temperature=0是必须的,编码任务要的是确定性,不是创造力。工具绑定之后,模型返回的 message 里会带tool_calls字段,我再用一个 ToolNode 或者手动执行这些调用。LangGraph 提供了预置的ToolNode,但我建议手动实现,因为编码场景下工具执行前后需要做很多额外处理,比如记录文件变更、检查命令安全性。

4.4 验证节点的测试执行

验证节点负责跑测试并判断结果。我用的策略是:如果项目里有 pytest,就跑 pytest;没有的话,至少跑一次语法检查(python -m py_compile)。测试输出截取最后 50 行,因为报错信息通常在末尾,前面的都是噪音。

def verify_node(state: AgentState): result = run_command.invoke({"cmd": "pytest -x -q"}) passed = "passed" in result and "failed" not in result return { "test_result": result[-2000:], "retry_count": 0 if passed else state["retry_count"] + 1, "status": "running" if passed else "retrying" }

注意retry_count在通过时要重置为 0,因为这是针对当前步骤的计数,进入下一步骤应该重新开始。这个细节如果搞错,会导致后续步骤莫名其妙地提前失败。

4.5 接口层的任务提交与状态查询

FastAPI 这边我设计了两个核心接口。提交任务用 POST,立即返回一个 task_id,任务在后台异步跑。查询状态用 GET,前端轮询或者用 WebSocket 推送。

from fastapi import FastAPI, BackgroundTasks from app.services.task_manager import create_task, get_task app = FastAPI() @app.post("/tasks") async def submit_task(payload: TaskRequest, bg: BackgroundTasks): task_id = create_task(payload.description) bg.add_task(run_agent, task_id, payload.description) return {"task_id": task_id} @app.get("/tasks/{task_id}") async def query_task(task_id: str): return get_task(task_id)

BackgroundTasks适合轻量后台任务,但编码任务可能跑几分钟,更稳妥的做法是用独立的 worker 或者任务队列。我项目里前期用 BackgroundTasks 够用,后期任务量上来了换成了独立进程池。这个演进路径供你参考,不用一上来就上重型方案。

5. 常见问题与排查技巧实录

5.1 模型不调用工具,直接编造答案

这是最常见的问题。模型看到任务描述,不去读文件,直接凭想象写代码。原因是 prompt 里没有强调“必须先读取相关文件”。我的解决办法是在系统 prompt 里加一条硬性规则:任何修改操作之前,必须先调用读文件工具确认当前内容。同时在执行节点里做检查,如果模型返回的是纯文本而没有 tool_calls,就注入一条提示消息让它重新来。

5.2 工具调用参数格式错误

模型有时候会把参数包成字符串,比如把{"path": "a.py"}传成"{\"path\": \"a.py\"}"。这是模型对工具 schema 理解不到位导致的。解决办法有两个:一是把工具参数的类型注解写清楚,Pydantic 模型比裸 dict 效果好;二是在工具执行前做一次容错解析,如果是字符串就尝试 json.loads。我两个都做了,现在基本不再出这个问题。

5.3 循环停不下来

前面提过三重保险,但还有一种情况是模型每次都能“通过”测试,但实际上什么都没改。这通常是因为测试命令写得太宽松,比如只跑了语法检查。我的对策是验证节点除了跑测试,还要检查files_changed是否为空。如果这一步应该改文件但一个文件都没动,直接判定失败。

5.4 常见问题速查表

问题现象可能原因解决方向
模型不调工具prompt 未强制加硬性规则 + 检查 tool_calls
参数格式错schema 不清晰用 Pydantic + 容错解析
循环不退出退出条件缺失重试上限 + 步骤上限 + 超时
状态丢失字段语义搞混区分追加与覆盖字段
接口超时同步执行任务改异步 + 后台任务
token 爆掉读整个大文件按行范围读取

5.5 几个我踩过的坑

第一个坑是状态字段的可变性。LangGraph 的状态在节点间传递时,如果你直接修改传入的 list 或 dict,可能会影响到其他分支。我一开始在 execute 节点里直接state["files_changed"].append(...),结果发现有时候变更记录会重复。后来改成返回新列表,让 LangGraph 自己合并,问题就没了。

第二个坑是模型选择。我试过用便宜的小模型跑规划,结果规划质量差到没法用,后面执行全乱套。规划节点值得用最强的模型,执行节点可以用稍弱的,验证节点几乎不需要模型。按节点分配模型,成本和效果能平衡得比较好。

第三个坑是测试环境隔离。Agent 跑测试的时候如果直接在主目录跑,可能会污染你的工作区。我后来给每个任务建了一个临时工作目录,把项目复制进去再跑,跑完清理。虽然多了一步复制,但安全多了。

6. 一些关于扩展方向的个人想法

羲和现在能处理单仓库内的编码任务,但离我理想中的状态还有距离。我最近在琢磨几个方向。一个是多轮任务记忆,让 Agent 记住之前做过什么,避免重复劳动,这个可以在状态里加一个长期记忆的存储层。另一个是并行步骤执行,LangGraph 支持并行节点,如果规划出的步骤之间没有依赖,完全可以同时跑,能省不少时间。

还有一个我觉得很有价值的是人工介入点。现在 Agent 是全自动的,但有些关键改动,比如删除文件、修改配置文件,最好能暂停下来等我确认。LangGraph 支持中断(interrupt),可以在特定节点前挂起,等外部信号再继续。这个机制我还在调,等稳定了再单独写一篇。

工具这块也可以继续加,比如代码检索工具(基于向量或者基于符号索引)、依赖分析工具、git 操作工具。工具越丰富,Agent 能处理的任务类型就越多。但工具不是越多越好,每加一个工具,模型的选择难度就上升一分,prompt 里得把每个工具的适用场景说清楚,否则模型会乱用。

最后分享一个我在调试时的小技巧:把 LangGraph 的执行过程用graph.stream()跑,每一步的状态变化都打印出来。这样你能清楚看到模型在哪一步做了什么决策、调了什么工具、返回了什么。比看最终结果有用得多,很多问题一眼就能定位。这个习惯我从做这个项目第一天保持到现在,强烈建议你也这么干。