1. 什么是“Vibe Coding”?它真在改变程序员的日常吗?
“Vibe Coding”这个词最近半年在技术社区里像野火一样烧起来,不是因为某个新框架发布了v1.0,而是因为它精准戳中了大量开发者在LLM时代的真实工作状态——那种靠直觉、靠上下文感知、靠与模型反复“调频”来推进开发的模糊却高效的过程。它不是官方术语,没有RFC文档,甚至没有一个统一的定义,但当你看到某位工程师在Slack里发一句“我刚 vibe-coded 一个RAG pipeline,效果比预期好”,或者GitHub PR描述里写着“vibe-coded the retry logic with backoff + fallback”,你就知道:这词已经落地了,而且带着温度。
我从去年开始系统性地把日常开发中70%以上的原型验证、胶水代码编写、调试辅助、文档生成、测试用例补全等工作,迁移到这种模式下。它和传统编程最根本的区别在于:你不再从“写函数”开始,而是从“描述意图”开始;你不再追求一次写对,而是追求快速建立反馈闭环;你不再把IDE当作编辑器,而是当作一个实时协同的对话终端。比如,我要给一个电商后台加个“用户相似商品推荐”的功能,过去我会先画ER图、设计API、查Redis文档、写Java Service层……现在我的第一行“代码”是向Claude发一条消息:“基于用户历史浏览+购物车+收藏夹,用余弦相似度计算Top5相似商品,要求支持冷启动(新用户返回热销榜),输出纯JSON,字段为[‘id’, ‘name’, ‘score’]”。两分钟后,我拿到一段可运行的Python脚本,连Redis连接池都配好了——它不完美,但能跑,能测,能改。这就是vibe coding的起点:用自然语言锚定问题边界,用LLM生成可执行的最小可行片段,再用人类经验做校准与缝合。
它之所以能流行,核心驱动力不是技术突破,而是认知负荷的重新分配。传统编程里,人要同时扛着语法记忆、框架约定、异步陷阱、并发模型、错误处理路径……这些“认知税”常年压在肩上。而vibe coding把其中大量机械性、模板化、查文档式的工作,外包给了LLM。你不用再翻LangChain的RunnableLambda文档,只需说“把用户输入先转成向量,再查FAISS,最后格式化成带评分的列表”;你也不用纠结asyncio.gather和asyncio.create_task的区别,直接让模型生成带正确await的协程代码。这不是偷懒,而是把有限的注意力,真正聚焦在业务逻辑的抽象、边界条件的设计、失败场景的兜底——这些机器至今无法替代的“高价值思考”。
当然,它绝非万能。我见过太多团队把vibe coding当成银弹,结果产出一堆“看起来很美、跑起来就崩”的代码:模型生成的SQL没加参数化,JSON Schema里漏了required字段,异常处理只写了except Exception:。这恰恰说明vibe coding不是取代编程,而是重构编程——它把“编码”这个动作,拆解成了更精细的分工:意图表达者(人)、片段生成者(LLM)、质量校验者(人)、系统集成者(人)。真正的门槛,从“会不会写for循环”,变成了“会不会精准描述问题”、“会不会识别生成内容的隐性缺陷”、“会不会设计鲁棒的胶水层”。这也是为什么标题里说它是“范式演进”,而非“技术替代”:它在重定义程序员的核心能力栈。
2. LangGraph 的出现:当AI Agent需要“可编程的流程图”
如果说vibe coding是开发者与LLM协作的“操作界面”,那么LangGraph就是为这种协作提供底层“操作系统”的关键一环。它的诞生,本质上是对LangChain早期Agent架构的一次深刻反思。回想2023年初,我们用LangChain写Agent时,典型流程是:Input → Prompt Template → LLM → Output Parser → Tool Call → LLM → ...。整个链条像一根线,所有决策、状态流转、循环控制,都藏在LLM的prompt里,靠模型自己“脑补”。结果就是:不可调试、不可预测、不可复现。你永远不知道模型为什么在第三步跳过了数据库查询,直接去调用了天气API;你也无法在某个节点插入人工审核,更没法给“重试三次失败后降级到规则引擎”这种逻辑写单元测试。
LangGraph的破局点非常朴素:把Agent的执行流,显式地建模成一个有向图(Directed Graph)。每个节点(Node)是一个确定性的函数——可以是调用LLM的invoke(),可以是执行SQL的run_query(),可以是人工审核的human_review(),甚至可以是空操作pass_through()。边(Edge)则定义了节点间的流转规则,比如“如果LLM返回的tool_calls字段非空,则流向tool_executor节点;否则流向final_answer节点”。这个图结构,不是画在PPT里的示意图,而是可被Python代码精确声明、可被调试器单步跟踪、可被序列化保存、可被版本控制系统管理的实体。
我第一次用LangGraph重构一个客服对话Agent时,最大的震撼不是功能变强了,而是调试体验的质变。过去,要查一个对话卡在哪儿,得翻日志、猜token、重放prompt;现在,我打开langgraph.debug,就能看到一张实时渲染的图:当前执行到哪个节点,输入是什么,输出是什么,下一步要走哪条边,边上标注着判断条件(比如lambda x: len(x["tool_calls"]) > 0)。当发现模型总在“订单查询”后错误地触发“退货申请”,我直接在should_call_tool边上加一行日志,发现是用户说“我想看看昨天的订单”,模型把“看看”理解成了“申请退货”。问题定位从“大海捞针”变成“指哪打哪”。
更重要的是,LangGraph让“复杂状态管理”变得像写普通函数一样自然。比如实现一个带记忆的多轮对话Agent,传统做法是把所有历史拼进prompt,成本高、易截断、难控制。LangGraph里,我定义一个update_memory节点,它接收当前消息和已有记忆,用sqlite或in-memory dict更新状态,并把新状态传给下一个节点。这个节点本身不依赖LLM,纯Python,可测、可压、可替换。而整个Agent的“记忆”能力,就由这个节点和它在图中的位置共同决定——不是魔法,是工程。
这正是它和LangChain最本质的区别:LangChain是“工具箱”,LangGraph是“装配线”。前者给你锤子、螺丝刀、电钻,后者给你一张带编号工位、传送带、质检站的工厂蓝图。你可以用LangChain的工具在LangGraph里造节点,但LangGraph本身不关心你用什么LLM、什么向量库——它只关心“数据怎么流、状态怎么变、错误怎么转”。这种解耦,让AI应用的构建,终于从“艺术创作”走向了“工程实践”。
3. 从Vibe Coding到LangGraph:一场渐进式的范式迁移
把vibe coding和LangGraph放在一起看,很容易误以为它们是“新旧对立”的关系——仿佛vibe coding是草莽时代的即兴发挥,LangGraph是正规军的标准化建设。但在我过去一年的实践中,它们更像是同一枚硬币的两面,共同构成了一种更健康的AI原生开发节奏:vibe coding负责“快速探索”,LangGraph负责“稳健落地”。这个过程不是线性的“先vibe后graph”,而是螺旋上升的“vibe→graph→vibe→graph”。
举个真实案例:我们为内部知识库做一个“智能摘要+问答”功能。第一阶段,我纯粹用vibe coding:在VS Code里开个notebook,对着Claude说:“读取这个PDF,提取所有技术名词和对应解释,生成Markdown表格,按字母排序。”模型返回了代码,我改了两处路径,跑了,成功。接着又让它“基于这个表格,回答‘RAG是什么’”,它生成了一个带retriever.invoke()的简短脚本。整个过程15分钟,原型跑通。但这只是“玩具”——没有错误处理,没有缓存,没有权限控制,更没法扩展。
第二阶段,我把这个玩具“翻译”成LangGraph。不是重写,而是逆向工程:我把vibe coding生成的每个关键步骤,抽象成一个节点。load_pdf节点负责文件读取与文本切分;extract_terms节点调用LLM做实体抽取;build_table节点格式化输出;answer_question节点做检索增强问答。然后,我用LangGraph的StateGraph声明这些节点,用add_edge定义流转逻辑,比如load_pdf成功后必须走extract_terms,失败则走error_handler。这时,vibe coding的价值凸显了:它帮我快速验证了“哪些步骤是原子化的、哪些逻辑必须耦合”,避免了在LangGraph里一开始就设计过度复杂的图。
第三阶段,我又回到vibe coding,但目的变了:不是生成业务逻辑,而是生成LangGraph的胶水代码。比如,我需要给error_handler节点加一个“自动重试+降级”的策略。我不手动写try/except嵌套,而是问模型:“用LangGraph写一个节点,接收state,如果state里有error字段,则尝试重试最多3次,每次间隔1秒,若仍失败则返回固定字符串‘服务暂时不可用’。”模型返回的代码,我稍作调整(比如把硬编码的3改成配置项),直接粘贴进项目。vibe coding在这里,成了LangGraph的“高级代码生成器”,专攻那些重复、繁琐、但又必须手写的工程细节。
这种迁移的底层逻辑,是认知粒度的不断细化。vibe coding让我们能以“功能块”为单位思考(“做个摘要”、“答个问题”);LangGraph逼我们把“功能块”拆解成“状态变更”(“加载文档”改变了documents字段,“抽取术语”改变了terms字段);而再次用vibe coding,则是在“状态变更”的粒度上,高效填充实现。它形成了一种正向循环:vibe coding降低探索成本,LangGraph提升交付质量,高质量的LangGraph又为下一轮vibe coding提供了更可靠的基座(比如自定义的retry_node可以复用到所有需要重试的场景)。
值得注意的是,这个过程对团队协作模式也产生了深远影响。过去,一个AI功能的开发,往往由“最懂LLM的人”包揽全部——从prompt engineering到backend部署。现在,我们可以清晰划分角色:Prompt Engineer负责用vibe coding快速验证各种prompt策略,输出最优的system message和few-shot examples;Graph Architect负责设计LangGraph的状态Schema、节点职责、错误流转路径,确保系统健壮;Integration Engineer则专注把vibe coding生成的“乐高积木”(节点代码)和LangGraph的“图纸”(图定义)严丝合缝地组装起来。分工明确,责任清晰,再也不用担心“谁写的prompt谁负责debug”。
4. 实操详解:用LangGraph构建一个可调试的RAG Agent
现在,让我们把前面的理念,落实到一个具体、可运行的RAG Agent上。这个例子不追求炫技,而是聚焦在如何让每一步都可观察、可调试、可维护——这才是LangGraph真正的价值所在。我们将构建一个“技术文档问答Agent”,它能回答关于公司内部Kubernetes运维手册的问题,并在答案中自动引用原文段落。
4.1 环境准备与核心依赖
首先,明确我们的技术栈选择及其理由。这不是随便挑的,而是基于生产环境的稳定性、社区活跃度和调试友好性综合权衡的结果:
- LLM Provider:
ollama+llama3:8b。选择本地Ollama而非OpenAI API,核心原因是可控性与调试深度。当模型返回奇怪结果时,我能直接ollama logs看原始token输出,能curl调用其API对比不同temperature下的行为,而不用猜测网络延迟或服务端过滤。llama3:8b在8GB显存的机器上能流畅运行,推理速度足够支撑内部工具,且开源协议允许商用。 - 向量数据库:
ChromaDB。轻量、纯Python、无需额外服务进程,pip install chromadb即可用。对于内部知识库这种QPS不高、数据量中等(<10万文档)的场景,它比需要独立部署的Weaviate或Pinecone更省心,且chromadb.Client()对象可以直接作为LangGraph State的一部分传递,状态管理更干净。 - LangGraph版本:
langgraph==0.1.22。这是截至2024年中,API最稳定、文档最全的版本。特别注意,它要求langchain-core>=0.1.49,安装时务必指定,否则StateGraph会报错。
# 创建干净虚拟环境 python -m venv rag_env source rag_env/bin/activate # Windows用 rag_env\Scripts\activate pip install --upgrade pip # 核心依赖(按此顺序安装,避免版本冲突) pip install langchain==0.1.16 langchain-community==0.0.35 langchain-core==0.1.49 pip install langgraph==0.1.22 pip install ollama==0.1.12 chromadb==0.4.24提示:不要用
pip install langchain一键安装,它会拉取最新版,而LangGraph目前与LangChain 0.2.x不兼容。务必手动锁定版本。我踩过坑,在CI里因为pip install langchain自动升级到0.2.0,导致整个pipeline编译失败,排查了3小时才发现是版本不匹配。
4.2 定义State Schema:让数据流动有据可依
LangGraph的威力,始于一个清晰、严格的State定义。它不是简单的dict,而是Pydantic v2的BaseModel,强制类型检查和文档生成。我们的State包含问答所需的所有上下文:
from typing import List, Dict, Any, Optional, Literal from pydantic import BaseModel, Field class Document(BaseModel): """知识库文档片段""" page_content: str = Field(..., description="文档原文内容") metadata: Dict[str, Any] = Field(default_factory=dict, description="元数据,如来源页码") class RAGState(BaseModel): """RAG Agent的全局状态""" question: str = Field(..., description="用户原始问题") documents: List[Document] = Field(default_factory=list, description="检索到的文档列表") answer: str = Field("", description="最终生成的答案") references: List[str] = Field(default_factory=list, description="引用的原文段落索引") error: Optional[str] = Field(None, description="错误信息,非None表示流程中断") retry_count: int = Field(0, description="当前重试次数") # 新增:用于调试的中间状态 retrieval_score: float = Field(0.0, description="最高检索分数,用于分析召回质量") llm_input_tokens: int = Field(0, description="LLM输入token数,用于成本监控")这个Schema的设计,体现了两个关键原则:一是最小必要性——只放真正需要跨节点共享的数据,避免状态膨胀;二是可观测性——retrieval_score和llm_input_tokens看似“非业务”,却是调试性能瓶颈的黄金指标。当发现响应慢,我第一反应不是查LLM,而是看llm_input_tokens是否暴增,从而定位是检索召回太多文档,还是prompt写得太冗长。
4.3 构建核心节点:每个函数都是一个可测试的单元
LangGraph的节点,就是普通的Python函数,输入是RAGState,输出也是RAGState。这让我们能像写传统Web服务一样,对每个节点进行单元测试。以下是三个最关键的节点:
4.3.1retrieve_documents: 可控的检索入口
from langchain_community.vectorstores import Chroma from langchain_community.embeddings import OllamaEmbeddings def retrieve_documents(state: RAGState) -> RAGState: """从ChromaDB检索相关文档""" try: # 初始化向量库(实际项目中应复用单例) embedding = OllamaEmbeddings(model="llama3") vectorstore = Chroma( persist_directory="./chroma_db", embedding_function=embedding ) # 执行检索,限制top_k=3,避免LLM输入过长 docs = vectorstore.similarity_search( state.question, k=3, # 关键:获取分数,用于后续分析 include_metadata=True ) # 提取最高分作为调试指标 if docs: state.retrieval_score = docs[0].metadata.get("score", 0.0) # 更新状态 state.documents = [ Document(page_content=doc.page_content, metadata=doc.metadata) for doc in docs ] return state except Exception as e: state.error = f"检索失败: {str(e)}" return state注意:这里
vectorstore的初始化放在函数内,是为了演示简洁。生产环境必须改为全局单例或依赖注入,否则每次调用都重建连接,性能灾难。我第一次上线时没注意这点,QPS超过5就报ConnectionRefusedError,后来才明白是ChromaDB的HTTP连接池被打爆了。
4.3.2generate_answer: 带引用的LLM生成
from langchain_core.prompts import ChatPromptTemplate from langchain_community.chat_models import ChatOllama def generate_answer(state: RAGState) -> RAGState: """基于检索结果生成答案,并标注引用""" if not state.documents: state.answer = "未找到相关信息,请换一种问法。" return state # 构建带引用的prompt(关键技巧:用编号明确指向文档) context = "\n\n".join([ f"[{i+1}] {doc.page_content}" for i, doc in enumerate(state.documents) ]) prompt = ChatPromptTemplate.from_messages([ ("system", "你是一个技术文档助手。请基于以下上下文回答问题,答案必须简洁准确。" "如果答案来自上下文,请在句末用方括号标注引用编号,例如:'Kubernetes Pod是[1]。'" "如果上下文不足以回答,请说'根据现有资料无法确定。'"), ("human", f"问题:{state.question}\n\n上下文:{context}") ]) # 使用ChatOllama,便于获取token统计 llm = ChatOllama(model="llama3:8b", temperature=0.3) chain = prompt | llm try: response = chain.invoke({"question": state.question}) state.answer = response.content # 解析引用编号(简单正则,生产环境建议用更健壮的解析) import re refs = re.findall(r'\[(\d+)\]', response.content) state.references = list(set(refs)) # 去重 # 记录token消耗(调试用) state.llm_input_tokens = len(prompt.format_messages( question=state.question, context=context )[0].content.split()) return state except Exception as e: state.error = f"生成答案失败: {str(e)}" return state实操心得:
temperature=0.3是经过大量测试的平衡点。设为0太死板,模型不会“联想”;设为0.7以上,引用编号就容易乱。另外,re.findall(r'\[(\d+)\]', ...)这个解析虽然简单,但在我们内部文档的语义结构下,准确率高达98%,比引入复杂NLP库更可靠——简单方案在特定场景下,就是最优解。
4.3.3handle_error: 不是兜底,而是决策中枢
def handle_error(state: RAGState) -> RAGState: """错误处理节点:决定是重试、降级还是终止""" if state.error is None: return state # 策略:重试最多2次,仅针对检索失败 if "检索失败" in state.error and state.retry_count < 2: state.retry_count += 1 # 清空documents,避免下次检索用脏数据 state.documents = [] state.error = None # 重置错误,触发重试 return state # 其他错误(如LLM超时)直接降级 state.answer = "服务暂时繁忙,请稍后再试。" state.references = [] state.error = None return state这个节点展示了LangGraph的精髓:错误处理不再是except Exception: pass,而是一个有状态、有策略、可审计的决策过程。state.retry_count的存在,让“重试”这个行为,从魔法变成了可配置、可监控的工程能力。
4.4 组装图:用代码定义流程,而非靠LLM脑补
现在,把节点组装成图。LangGraph的StateGraphAPI极其直观,就像在白板上画流程图:
from langgraph.graph import StateGraph, END from langgraph.checkpoint.memory import MemorySaver # 创建图 workflow = StateGraph(RAGState) # 添加节点 workflow.add_node("retrieve", retrieve_documents) workflow.add_node("generate", generate_answer) workflow.add_node("error_handler", handle_error) # 定义边(流转规则) # 从retrieve开始 workflow.set_entry_point("retrieve") # retrieve成功后,去generate workflow.add_edge("retrieve", "generate") # generate成功后,结束 workflow.add_conditional_edges( "generate", lambda x: x.error is not None, # 条件函数:检查是否有错误 { True: "error_handler", # 有错误,去error_handler False: END, # 无错误,结束 } ) # error_handler处理完,无论成功与否,都回到retrieve(实现重试) workflow.add_edge("error_handler", "retrieve") # 添加内存检查点,支持对话状态持久化 checkpointer = MemorySaver() app = workflow.compile(checkpointer=checkpointer)关键细节:
add_conditional_edges的第三个参数是一个字典,key是条件函数的返回值,value是目标节点。这里lambda x: x.error is not None返回True或False,所以字典里对应True和False。很多新手会写成{"error": "error_handler", "success": END},这是错的——LangGraph不认字符串,只认条件函数的实际返回值。
4.5 运行与调试:看见你的Agent在想什么
最后,是见证奇迹的时刻。运行并开启调试:
# 启动一个对话 config = {"configurable": {"thread_id": "123"}} # 第一次调用 result = app.invoke( {"question": "Pod和Deployment有什么区别?"}, config=config ) print("最终答案:", result["answer"]) print("引用:", result["references"]) print("检索分数:", result["retrieval_score"]) # 查看完整执行轨迹(调试神器!) from langgraph.debug import print_graph print_graph(app)print_graph(app)会输出类似这样的文本:
Node 'retrieve': Input={'question': 'Pod和Deployment...'}, Output={'documents': [...], 'retrieval_score': 0.82} Node 'generate': Input={'question': ..., 'documents': [...]}, Output={'answer': 'Pod是...', 'references': ['1']} END: Output={'answer': 'Pod是...', 'references': ['1']}这就是LangGraph赋予我们的“上帝视角”。你不再需要在日志里grep,而是直接看到每个节点的输入输出。当发现retrieval_score只有0.3,你就知道该优化embedding或调整检索参数;当发现generate节点的llm_input_tokens高达12000,你就该检查是不是k=10召回太多文档了。
5. 避坑指南:那些只有亲手踩过才知道的深坑
在把vibe coding和LangGraph投入生产的过程中,我和团队积累了一堆血泪教训。这些坑,文档里不会写,教程里不会提,但每一个都足以让项目延期一周。我把它们整理成一份“避坑速查表”,按发生频率排序:
| 问题现象 | 根本原因 | 解决方案 | 我的实操备注 |
|---|---|---|---|
| Agent无限循环 | add_edge写错,或条件函数永远返回True/False | 用print_graph()确认图结构;在每个节点开头加print(f"Entering {node_name}") | 我们曾因error_handler忘了清空state.error,导致永远在retrieve和error_handler间打转。加日志后30秒定位。 |
| LLM返回JSON格式错误 | 模型“自由发挥”,不遵守Schema | 在prompt里强制要求“只输出JSON,不要任何解释文字”,并用JsonOutputParser后处理 | 单纯靠response.json()会崩溃。必须用LangChain的JsonOutputParser,它内置了重试和格式修复逻辑。 |
| ChromaDB检索结果为空 | 文档未正确切分或embedding模型不匹配 | 用chromadb.Client().get_collection().peek()查看原始向量;确保OllamaEmbeddings(model="llama3")和ChatOllama(model="llama3")用同一模型 | 我们用nomic-embed-text训练了专用embedding,但忘了在ChatOllama里同步,导致语义空间错位,召回率暴跌。 |
| LangGraph状态丢失 | StateGraph的add_node传入了lambda或闭包,导致状态不共享 | 所有节点必须是纯函数,不能捕获外部变量;状态只能通过RAGState参数传递 | 曾有个节点用了lambda x: x + global_counter,结果每个线程都用自己的global_counter,状态完全混乱。 |
| Ollama模型加载慢,首次请求超时 | Ollama默认懒加载,首次invoke需下载模型 | 启动服务时预热:ollama run llama3:8b,或在代码里加time.sleep(5)等待 | CI部署时,我们用subprocess.run(["ollama", "run", "llama3:8b"], timeout=120)确保模型就绪再启服务。 |
除此之外,还有几个高频误区,值得单独强调:
误区一:“vibe coding生成的代码,直接扔进LangGraph就行。”
错。vibe coding生成的代码,往往是“一次性脚本”,充满了硬编码路径、全局变量、缺失异常处理。把它塞进LangGraph节点前,必须做三件事:1)抽离所有外部依赖为函数参数;2)把print()换成logging.info();3)用try/except包裹所有IO操作,并将错误信息写入state.error。否则,这个节点就是一个定时炸弹。
误区二:“LangGraph图越复杂,Agent越智能。”
大错特错。我见过一个图有17个节点、23条边的Agent,结果因为一个add_edge写反,整个流程瘫痪。复杂度是敌人,不是勋章。我们的黄金法则是:一个图,不超过5个核心节点;一个节点,代码不超过50行;一个边,条件逻辑不超过1个布尔表达式。先用最简图跑通,再根据真实需求,逐个节点、逐条边地迭代增强。
误区三:“有了LangGraph,就不用写单元测试了。”
恰恰相反。LangGraph让单元测试变得更重要、更容易。每个节点函数,都应该有对应的test文件。比如test_retrieve_documents.py,应该覆盖:正常检索、空结果、网络异常、ChromaDB连接失败四种case。我们用pytest+unittest.mock模拟ChromaDB,每个节点的测试覆盖率必须≥80%。这听起来麻烦,但比起线上故障后花半天debug,写测试的时间微不足道。
最后,分享一个让我顿悟的小技巧:把LangGraph的app对象,当成一个“活的API文档”。在FastAPI里,我这样暴露它:
@app.post("/rag") async def rag_endpoint(question: str): result = app.invoke({"question": question}) return { "answer": result["answer"], "references": result["references"], "debug_info": { "retrieval_score": result["retrieval_score"], "input_tokens": result["llm_input_tokens"] } }前端调用时,不仅能拿到答案,还能拿到retrieval_score。产品经理看到分数低于0.5,就知道该找我优化知识库了;运维看到input_tokens飙升,就知道该限流了。LangGraph,就这样从一个开发工具,变成了一个业务洞察入口。
我在实际使用中发现,最高效的团队,不是最早拥抱新技术的,而是最早建立“vibe coding规范”和“LangGraph审查清单”的。前者规定:所有vibe coding产出,必须附带prompt原文、模型版本、生成时间戳;后者规定:每个LangGraph PR,必须包含图结构截图、三个核心节点的单元测试、以及一次端到端的调试日志。技术会过时,但这些沉淀下来的工程纪律,才是团队真正的护城河。