Intro
刚做完一个叫 AI Interview Platform 的项目——用 FastAPI 搭的面试平台,核心是一套 RAG:把岗位 JD 切块、向量化、塞进 Milvus,再让大模型基于检索到的内容做模拟面试,答案通过 SSE 一段一段往外流。
那个项目教给我一件事:大模型很能「说」。给它喂对上下文,它能对着岗位要求答得有模有样。
于是我就想,光会说还不够,能不能让它自己「做」点事——不是回答”怎么搜索”,而是真的打开浏览器、点按钮、敲关键词,把事办成。上一个项目里我用了 LangChain,这一回想更底层一点,每一层都自己搭,坑也一个个自己踩。
于是就有了这个 Web Agent,一个桌面端的 AI 浏览器智能 RPA。给它一句自然语言:
在 Bing 中搜索 OpenAI,并打开第一条搜索结果它就会自己启动浏览器、自己看页面、自己想下一步、自己动手点按钮敲文字,循环往复,直到任务完成。
拆开来看,整个流程就是五步:
- 自动启动浏览器
- 采集当前页面的状态
- 把状态喂给大模型,让它决策
- 执行浏览器操作
- 循环以上步骤,直到任务完成
说起来简单,做起来全是细节。这篇就按搭建的顺序,把过程和踩过的坑都记下来。
先交代一句分工:项目里 webagent/ 核心引擎(感知、执行、决策、状态机、持久化)是我自己写的;外面的 api/(FastAPI)和 frontend/(React)是 AI 生成的,我做的是定架构和联调。博客主要讲核心引擎,外壳放到最后带一笔。
搭环境:uv、playwright 和一个中文路径的坑
项目用 uv 管理,uv init 自动生成 pyproject.toml 和 .venv。依赖清单不展开,挑主要的几个:
dependencies = [ "playwright>=1.47.0", # 浏览器引擎 "playwright-stealth>=1.0.6", # 反检测 "pyautogui>=0.9.54", # 桌面窗口控制、坐标兜底点击 "openai>=1.40.0", # AI 决策(OpenAI 兼容协议) "transitions>=0.9.2", # 有限状态机 "fastapi>=0.115.0", # HTTP API 层 "uvicorn>=0.30.6", # ASGI 服务器 "pywebview>=5.1", # 桌面窗口壳(内嵌 WebView2) "pydantic>=2.8.2", # 配置解析 + 数据结构 "sqlalchemy>=2.0.32", # 任务/步骤持久化 "tenacity>=8.5.0", # LLM 调用重试]
[tool.uv]package = falsepackage = false 这行是被坑过才加的:默认情况下 uv 会生成 .pth 文件,在 Windows 中文路径下,这个文件里的绝对路径带中文,后续会有各种莫名其妙的问题。关掉包安装就清净了。
uv sync --all-groupsuv run playwright install chromium第二条是必须的——playwright 只是控制库,chromium 才是浏览器内核,不装的话浏览器根本起不来。装完用几个 import 验证一下就算部署完成。
然后是 .env 配置。这里用 pydantic-settings 做了个嵌套映射:
WEBAGENT_AGENT__MAX_STEPS=25→ 映射到 Settings().agent.max_steps = 25WEBAGENT_ 是前缀,AGENT 对应根配置的 agent 字段,__ 是嵌套分隔符,MAX_STEPS 对应子配置的字段名。环境变量、.env 文件、代码默认值三层自动打通,不用到处写 os.getenv()。
浏览器有两种启动模式,一开始没搞明白区别,用下来才分清:
- launch:每次启动一个全新的 Playwright Chromium,适合不需要登录态的操作
- cdp:通过 CDP 协议接管你已经打开的 Chrome/Edge(要先带
--remote-debugging-port=9222参数启动浏览器),适合需要登录状态的场景,比如操作管理后台
架构与 core 层:先把地基打牢
正式写逻辑之前,先把架构分层定下来:
| 层级 | 职责 |
|---|---|
| 外壳(api/frontend) | FastAPI 服务 + React 前端 + pywebview 桌面壳(这层是 AI 生成的,我负责定设计和联调) |
| agent | 状态机驱动的自主循环 + 多模态大模型客户端 |
| perception | 双模态感知:DOM 结构化数据 + 页面截图 |
| executor | 浏览器管理 + 动作执行引擎 |
| core | 配置、日志、异常、数据结构、持久化 |
数据自上而下流动,核心是 agent 层那个「感知 → 思考 → 执行」的循环。
core 层最先写的是 config.py,也就是整个项目的配置中心。有几个点值得记一下。
目录定位。 用 Path(__file__).resolve().parents[2] 从当前文件往上跳两级找到项目根(core/config.py → core/ → webagent/ → 项目根),不管从哪个目录运行都能找对位置。另外还加了个打包兼容:
if getattr(sys, "frozen", False): # PyInstaller 打包模式 PROJECT_ROOT = Path(sys.executable).resolve().parentelse: PROJECT_ROOT = Path(__file__).resolve().parents[2]打包成 EXE 后 __file__ 就不可靠了,得改用 EXE 所在目录,数据文件才能落在用户能找到的地方。
嵌套配置的默认值陷阱。 根配置里挂子配置时,我第一版是这么写的:
# ❌ 类定义时就创建了实例,所有 Settings 共享同一个子对象agent: AgentSettings = AgentSettings()
# ✅ 每次创建新 Settings 时才创建新的 AgentSettingsagent: AgentSettings = Field(default_factory=AgentSettings)default_factory 接收一个工厂函数,每次需要默认值时才调用。直接赋实例的话,多个 Settings 对象会意外共享同一份子配置,这种 bug 排查起来很折磨。
单例。 配置对象全局只需要一份,用 lru_cache 实现:
@lru_cache(maxsize=1)def get_settings() -> Settings: for d in (DATA_DIR, LOG_DIR, SCREENSHOT_DIR): d.mkdir(parents=True, exist_ok=True) # 幂等:目录已存在也不报错 return Settings()第一次调用时创建并缓存,之后都返回同一个实例。顺手在创建前把 data/logs/screenshots 三个目录建好——mkdir 加 parents=True, exist_ok=True 就是幂等的,调多少次结果都一样,不用担心重复创建报错。
其实模块级变量(settings = Settings())也能当单例,还更简洁,但那样没法在创建前塞初始化逻辑,所以这里选了函数式。
数据结构(schemas)。 整个项目的数据契约都用 Pydantic v2 定义,最核心的是 Action:
ActionType=Literal[ "click", "input", "goto", "press_key", "wait", "scroll", "back", "refresh", "done", "error"]
class Action(BaseModel): action_type: ActionType dom_selector: str | None = None screen_x:int |None=None screen_y:int |None=None # ...
def is_terminal(self)->bool: return self.action_type in ("done","error")用统一数据契约的好处是:非法数据在构造时就被 pydantic 拦下来,不至于流到执行层才炸出类型错误。is_terminal() 标记 done 和 error 这两个终态动作,状态机收到就停。
日志与异常。 日志用 loguru,双输出:控制台 + 按日期轮转的文件。文件那个 sink 特意开了 enqueue=True 异步写入,免得日志 IO 阻塞主循环。初始化用了个模块级 _INITIALIZED 标志防重复配置——这也是笔记里”幂等”的又一次应用,init_logger() 被多少处调用,日志配置都只会生效一次。异常则按层分了家:BrowserError、PerceptionError、LLMError、ActionExecutionError……全部继承一个 WebAgentError 基类。上层想一网打尽就 catch 基类,想精确处理就 catch 具体的那个。
持久化。 SQLAlchemy 2.x 定义两张表:TaskRow 存任务,StepRow 存步骤,动作整个以 JSON 列落库,每一步再关联一张截图路径。外面再包一层 repository 提供只读查询。建表用的是 Base.metadata.create_all()——同样是幂等的,表存在就跳过,所以每个命令入口都能放心地先 init_db()。
冒烟测试:一层一层地往上盖
我的习惯是每写完一层,先配一个冒烟测试,通了再往上盖。主入口挂了六个自检命令(python -m webagent <command>),各有各的验证目标:
- self-check:打印项目根目录、Python 版本、各配置项(api_key 只显示
***),验证配置、日志、数据库链路 - browser-smoke:打开 example.com 截图,顺便执行
navigator.webdriver检查,确认反检测真的生效了 - perceive-smoke:打开 Bing 采集一次,验证元素数量、视口内数量、截图能解码落盘、DOM 上下文渲染
- llm-smoke:预造三种”脏输出”喂给解析器——纯 JSON、包在代码围栏里的、前后夹带自然语言的,全都得解析成功;配了 api_key 才跑真实调用
- executor-smoke:在 Bing 搜索框真实输入并回车,验证 DOM 定位路径;再故意构造一个既没 selector 也没坐标的非法 click,确认它老老实实抛出
ActionExecutionError - loop-smoke:不连浏览器,空转一遍状态机,手动触发
start() → perceived() → decided() → executed() → finish_done(),打印每一步的状态变化
其中 llm-smoke 那个”故意喂脏数据”的用例我挺得意:解析器的三层兼容不是写完就算,得真的拿三种脏输入考它一遍。冒烟测试的意义就在这——把”以后某天会炸”提前成”现在就炸,但炸在我手里”。
浏览器层:启动、隐身和一个 win32 的误报
executor/browser.py 负责浏览器的启动、接管和资源释放。
launch 模式下,启动参数里先关掉自动化痕迹,再套上 playwright-stealth:
self._browser=self._pw.chromium.launch( headless=self.settings.headless, args=[ "--disable-blink-features=AutomationControlled", "--start-maximized", # ... ])self._context=self._browser.new_context( viewport={"width":1440,"height":900}, user_agent=_DEFAULT_UA, locale='zh-CN', timezone_id='Asia/Shanghai',)Stealth().apply_stealth_sync(self._context)--disable-blink-features=AutomationControlled 加 stealth,双管齐下抹掉 navigator.webdriver 之类的自动化指纹——不然有些网站一眼就看出来的是脚本。
窗口激活这块踩了个挺隐蔽的坑。Agent 操作浏览器前需要把窗口激活置顶(后面坐标兜底点击依赖这个),我用 pygetwindow 找到窗口后调 activate(),结果 Windows 时不时抛异常。仔细看报错才发现:异常消息里写着”操作成功完成”。也就是说它其实成功了,但 win32 的接口还是抛了个异常出来。最后只能在异常处理里识别这种”误报”:
except Exception as e: msg=str(e) if "0" in msg and ("操作成功完成" in msg or "successfully" in msg.lower()): log.debug(f"已激活此窗口(win32误报):{target.title}") else: raise BrowserError(f"激活窗口失败: {e}")很无语,但确实管用。
另一个值得记的设计是 page 的访问器。agent 执行过程中,页面有可能被意外关掉,所以每次取 page 都带健康检查:
@propertydef page(self)->Page: if self._page is None: raise BrowserError("浏览器未启动") try: closed=self._page.is_closed() except Exception: closed=True if closed and self._context is not None: pages=[p for p in self._context.pages if not p.is_closed()] if pages: self._page=pages[-1] # 自动切换到最新的 page else: raise BrowserError("context中没有可用的page") return self._page用 @property 把它包成属性,调用方写 browser.page 就行,不用关心背后的检查逻辑。原来那个 page 没了,就自动切到 context 里最新的那个,循环不至于直接崩掉。
顺带一提,BrowserManager 实现了 __enter__ / __exit__,所以能 with BrowserManager() as browser: 这样用——退出 with 块自动关闭浏览器、释放资源,中间报错也一样。这个资源管理的写法在每层都用得上。
感知层:给大模型递一份”带批注的现场照片”
大模型看不见浏览器,感知层的任务就是把”当前页面长什么样”翻译成它能消化的东西。我选了双模态:DOM 结构化数据(文字)+ 页面截图(画面)一起喂。只给截图,模型猜不准元素怎么点;只给 DOM,它又读不懂那些没有语义标签的画面。
核心是一段注入浏览器执行的 JavaScript,扫一遍页面,把所有可交互元素揪出来(链接、按钮、输入框、带 role 属性的、绑了 onclick 的),然后给每个元素钉一个临时的身份牌:
// 清理上一次注入的 data-wa-id,避免污染document.querySelectorAll('[data-wa-id]').forEach(el => el.removeAttribute('data-wa-id'));
// 标记 data-wa-id,作为稳定 selector 的兜底el.setAttribute('data-wa-id', String(idx));let selector = `[data-wa-id="${idx}"]`;这一步是整个感知层的关键。页面上的元素千奇百怪,有的没 id,有的 class 是一串哈希,指望大模型自己猜 CSS 选择器不现实。而 data-wa-id 是我亲手注入的编号,[data-wa-id="12"] 这种选择器,Playwright 一找一个准。
筛选时也做了过滤:getBoundingClientRect() 面积大于 0、没被 display:none 藏起来的才算数,不然会把一堆看不见的元素也报给模型。
元素不能全量喂——token 会爆,模型注意力也会被稀释。所以先排序再截断:
// 排序:视口内优先 → 面积大优先 → DOM 顺序result.sort((a, b) => { if (a.inViewport !== b.inViewport) return a.inViewport ? -1 : 1; if (a.area !== b.area) return b.area - a.area; return a.index - b.index;});// ...elements: result.slice(0, maxElements),视口内的优先、大的优先,超出上限(默认 60 个)的直接砍掉。
截图这边,原图动辄几 MB,转 base64 塞进请求里太烧 token,所以用 Pillow 做等比压缩,最长边压到 1280:
if max(img.size)>max_edge: ratio=max_edge/max(img.size) new_size=(int(img.size[0]*ratio),int(img.size[1]*ratio)) img=img.resize(new_size,Image.LANCZOS)最终一次感知产出一个 PageSnapshot:URL、标题、文本摘要、可交互元素清单(带 selector 和 bbox 坐标),加一张 base64 截图。
写这个类的时候顺便理清了实例方法、静态方法的区别——capture 要用 self.cfg 读配置,是实例方法;render_dom_context 只做纯字符串拼接、不碰任何实例状态,就标成 @staticmethod。静态方法相当于”被类收编的普通函数”,逻辑上属于这个类但又不依赖状态,放进来比丢在模块顶层更内聚,读代码的人也能一眼看出它不碰实例。
执行层:一张字典表,十种动作,三层兜底
模型决策好了,轮到执行层动手。十种动作怎么分发?最直觉的写法是一长串 if-else,但我最后用的是字典映射:
# ❌ 不用字典映射:10种动作=10个分支,每加一种动作都要回来改主入口if action.action_type == "click": self._click(action)elif action.action_type == "input": self._input(action)# ...
# ✅ 字典映射后:查表取函数 → 判空兜底 → 统一调用handler={ "click":self._click, "input":self._input, "goto":self._goto, # ... "done":lambda _a:True, "error":lambda _a:True,}.get(action.action_type)if handler is None: raise ActionExecutionError(f"未知action_type: {action.action_type}")handler(action)self._click 不加括号,存的是函数对象本身;done/error 是终态动作,不用真的操作浏览器,lambda _a: True 占个位。参数名写成 _a 是 Python 的约定:收到了但不用。之所以不干脆去掉参数,是因为要保证所有 handler 的接口签名一致,查表调用才不会报错。
每个动作外面还包了一层单步重试(终态动作除外),应对偶发的元素没加载完:
retries=0 if action.is_terminal() else self.cfg.action_retryfor attempt in range(retries+1): try: handler(action) return True except Exception as e: log.warning(f"执行动作第{attempt+1}次失败: {e}") time.sleep(0.4)点击动作是这里最讲究的,做了三层兜底:
def _click(self,a:Action)->None: if a.dom_selector: try: self._dom_click(a.dom_selector) # 第一层:DOM 精准定位 return except Exception as e: log.warning(f"点击DOM元素失败,尝试坐标兜底: {e}") if a.screen_x is not None and a.screen_y is not None: self._coord_click(a.screen_x,a.screen_y) # 第二、三层:坐标点击 return为什么要有退路?因为真实网页不是温室:iframe 嵌套、动态渲染、懒加载,都可能让 DOM 选择器当场失效。这时候模型从截图里看到的坐标就是最后的退路。
坐标兜底内部还分两级:先用 Playwright 的 page.mouse 在视口里点(移动带步进,模拟人手),失败了再升级到 pyautogui 点屏幕绝对坐标。后者有个坐标系问题——pyautogui 以整个屏幕左上角为原点,而模型给的坐标是视口坐标,所以要现场算一次窗口偏移:
offset=self.page.evaluate( "()=>({sx:window.screenX,sy:window.screenY," "ox:window.outerWidth-windows.innerWidth," "oy:window.outerHeight-windows.innerHeight})")abs_x=int(offset["sx"]+offset["ox"]+x)abs_y=int(offset["sy"]+offset["oy"]+y)窗口位置 + 边框偏移 + 视口坐标 = 屏幕绝对坐标。算错一个像素都点不中,这段我是盯着调试输出一点点磨出来的。
输入动作有个小细节:fill 清空之后用 type(text, delay=30) 逐字输入,30 毫秒的间隔模拟人类打字节奏,顺便也能规避一些对”瞬间填充”敏感的反爬检测。
决策层:一份系统提示词和三层 JSON 解析
真正决定这个项目智商的,是喂给大模型的 System Prompt。写它没什么玄学,就是五条:设定角色、说明输入、规定输出格式、给 few-shot 示例、定规则。规则里最要命的几条:
- 必须输出单个 JSON 对象,严禁输出 JSON 以外的任何内容- 优先使用 dom_selector,仅在确实无 selector 可用时给出 screen_x/screen_y- 不要重复执行历史中已经失败/无效的动作- input 元素是输入框,点击输入框只会聚焦,不会提交表单; 输入完成后优先用 press_key(Enter)提交最后一条是被模型教育出来的。早期测试时,它对着 Bing 搜索框输入完关键词,非常自信地又点了一次搜索框——只是聚焦,什么都没发生。它就这么在”点击输入框”和”再点一次”之间反复横跳。所以在提示词里把这类常识直接写死。
就算规定了”必须输出 JSON”,模型输出也经常带杂质,解析端做了三层兼容:
text = raw.strip()if text.startswith("```"): # 1. 剥掉 ```json 代码围栏 text = text.strip("`") if text.lower().startswith("json"): text = text[4:] text = text.strip()if not text.startswith("{"): # 2. 前后夹带自然语言 → 首尾大括号截取 start=text.find("{") end=text.find("}") if start>=0 and end>start: text=text[start:end+1]# 3. json.loads + Action.model_validate 双重校验调用层面两个点。一是 temperature=0.2,把发散程度压低——Agent 要的是稳定执行,不是创意写作。二是 tenacity 指数退避重试:1 秒、2 秒、4 秒,最多三次,网络抖一抖也不至于整个任务崩掉。
还有个容易被忽略的兼容性问题:我想用 response_format={"type":"json_object"} 强制模型输出 JSON,但部分供应商不支持这个参数,直接抛 TypeError。所以专门 catch 了它,回落成普通请求:
try: resp=self._client.chat.completions.create(..., response_format={"type":"json_object"})except TypeError: resp=self._client.chat.completions.create(...) # 回落真正让决策质量上了一个台阶的,是 Reflexion 机制——每一轮把上一步的执行结果”反馈”给模型,让它学会回头看:
if last_error: reflexion_parts.append( "【上一步失败】\n" f"错误:{last_error}\n" "请换思路:换selector/滚动/等待/改用screen_x/y坐标兜底" )else: if history: page_still=(prev_url==snapshot.url) and (prev_title==snapshot.title) if page_still: reflexion_parts.append( "【警告】上一步动作已执行但页面URL和标题均未变化,该动作很可能无效!" "严禁重复相同动作,请改选其他元素或换操作方式" ) else: reflexion_parts.append("【上一步成功】...请检查任务是否已经达成")失败了告诉它为什么、建议换思路;成功了但页面纹丝不动,提醒它”刚才那下八成白点了”;页面跳转了就告诉它从哪跳到哪。最后再补一条完成守则:任务目标已达成必须立即输出 done。这条也是被坑出来的——早期模型任务明明做完了还在那儿兢兢业业地滚动、等待,不拦着它,步数预算全被磨蹭光。
状态机:红绿灯与三重保险
循环跑起来之后最怕的就是失控。流程控制这块我用 transitions 库写了个轻量的有限状态机,七个状态写死在代码里:
_STATES = ["idle", "perceiving", "reasoning", "executing", "checking", "done", "failed"]
transitions=[ {"trigger": "start", "source": "idle", "dest": "perceiving"}, {"trigger": "perceived", "source": "perceiving", "dest": "reasoning"}, {"trigger": "decided", "source": "reasoning", "dest": "executing"}, {"trigger": "executed", "source": "executing", "dest": "checking"}, {"trigger": "loop", "source": "checking", "dest": "perceiving"}, {"trigger": "finish_done","source": "*", "dest": "done"}, {"trigger": "fail", "source": "*", "dest": "failed"},]它像个红绿灯:idle → perceiving → reasoning → executing → checking → perceiving → ... 一圈圈规规矩矩地转。auto_transitions=False 禁掉自动转换,禁止瞬移——每次状态变更必须由代码显式触发。
有一点要想清楚:状态机只管流程,不做决策。决定”下一步点哪”的是大模型,状态机只保证”感知 → 推理 → 执行”的顺序不被踩乱。
选 transitions 而不是 LangGraph,主要是场景匹配:我的流程就是一条线性循环,而 transitions 轻量、改起来直观、出错一眼能看懂。LangGraph 的强项是动态图节点、条件路由、多分支并行,真要走到那一步再考虑搬家。
防失控上了三重保险:
最大步数。 max_steps 是物理上限,超了直接判失败。给一个绝对的底线,比追求”让它自己想办法”靠谱得多。
死循环检测。 同一个动作,连续成功执行三次,且页面 URL 和标题都没变——判定为原地打转,终止:
cur_key=(action.action_type,action.dom_selector,action.input_text,action.url)if ok and not page_changed and cur_key == ctx.repeat_key: ctx.repeat_count+=1else: ctx.repeat_count=1 if ok else 0 ctx.repeat_key=cur_key if ok else None
if ctx.repeat_count>=3: log.error("检测到死循环:同动作连续 {} 次执行且页面未变化,终止任务", ctx.repeat_count) ctx.task.status=TaskStatus.FAILED self.fail() break用户取消。 取消信号通过 threading.Event 跨线程传递(后面 api 层的取消接口就是置位这个 Event),主循环在每一步的检查点感知它。等待页面渲染的那段 sleep 也顺便做成了 cancel_event.wait(timeout=...),取消信号一来就能立刻醒,不用干等满整个间隔。
另外每一步执行完都会截图存档,配合前面持久化那节的任务/步骤记录,做到全链路留痕——任务失败的时候,能拿着截图一步步回溯它到底干了什么。
外壳层:AI 写的部分,和我留的钩子
前面交代过,api/ 和 frontend/ 是 AI 生成的。设计稿是我定的:FastAPI 提供 REST + WebSocket,React 前端做监控/历史/日志/设置四个页面,最后用 pywebview 包成桌面窗口,PyInstaller 打成单个 EXE。依赖方向严格单向——api 可以 import webagent,前端只能通过 HTTP/WS 访问 api,不许越界。
这层能接得上,靠的是我在写 AgentLoop 时就预留的两个钩子:
AgentLoop( on_step=self._on_step, # 每执行一步就回调,api 层把它转成 WS 事件推给前端 cancel_event=self._cancel_event, # 取消信号,REST 接口置位即可)on_step 回调让前端的实时步骤面板不需要轮询;cancel_event 让”点停止按钮”和”循环内检查取消”之间只隔一个 Event。Playwright 是单实例的,所以 api 层同时只允许跑一个任务,请求撞上了直接返回 409。还有一个细节:配置页返回的 api_key 永远打码(sk-****),保存时如果用户没改就保留旧值,get_settings.cache_clear() 之后下个任务自然生效。
看着 AI 把这层搭出来,再对上我预留的钩子严丝合缝的那一刻,感觉挺微妙的:我手写的那个”感知-推理-执行”循环,就这样被接上了一个能看、能点、能停的壳。核心是自己的,外围交给工具,分工清楚,效率确实高。
尾声
现在它已经能安安静静跑完整套流程了:启动浏览器,采集页面,思考,点击,输入,回车,跳转,确认结果,输出 done。
回头看这半个月,其实是条挺清楚的路:先让大模型会「说」,再让它会「做」。上一个项目要解决的是”你知不知道”——靠 RAG 把知识塞进去,让它答得准;这个项目要解决的是”你点不点得中”——靠感知和执行,让它动得对。方向不同,用的却是同一套笨功夫:把输入想清楚、把输出约束死、每一步都留痕、给失控设好底线。
所谓 Agent,拆开看无非是这些朴素的东西:一份把规矩讲清楚的提示词,一套看得清页面的感知,一双知道退路在哪的手,和一个不允许闯红灯的红绿灯。智能不在某个高深的模块里,而在这些笨功夫咬合在一起的地方。
下一步打算给它加上历史任务的记忆,让它记得上一次类似任务是怎么完成的。至于更远的以后,走一步看一步吧。
部分信息可能已经过时