agent.py 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208
  1. """
  2. ReAct 循环(Reason 想 + Act 做)。
  3. ReAct = Reason(想)+ Act(做)。
  4. 它跟普通聊天的区别只有一句话:**普通聊天是"问一句答一句",
  5. ReAct 是"想一步、做一步、看结果、再想下一步",能循环好几轮。**
  6. 这个循环的流程图如下,本文件就是它的代码版:
  7. ┌──────────────────────────────────────┐
  8. │ 问题 │
  9. │ ↓ │
  10. │ 思考(Thought) ← 模型自己想 │
  11. │ ↓ │
  12. │ 行动(Action) ← 模型点名要哪个工具 │
  13. │ ↓ │
  14. │ 观察(Observation)← 我们执行工具 │←─┐
  15. │ ↓ │ │ 没做完
  16. │ 做完了吗? ──没完───────────────────┼──┘
  17. │ │完 │
  18. │ ↓ │
  19. │ 最终答案(Final Answer) │
  20. └──────────────────────────────────────┘
  21. ⚠️ 这里故意**没用任何框架**,全是手写的。因为 ReAct 的骨架就这么点东西,
  22. 看懂这几十行,一个智能体内部在干什么就清楚了。
  23. """
  24. from __future__ import annotations
  25. import re
  26. from typing import Any, Dict, List, Tuple
  27. from .context import ContextBuilder
  28. from .llm import LLM
  29. from .memory import Memory
  30. from .tools import ToolRegistry
  31. # ----------------------------------------------------------------------
  32. # 系统提示词 —— 这封信决定了 Agent 的性格和纪律
  33. # ----------------------------------------------------------------------
  34. REACT_SYSTEM_PROMPT = """你是一名集装箱船配载助理,服务于码头配载员。
  35. 你的能力边界:
  36. - 你可以调用下面列出的工具来获取准确结果。
  37. - 你不知道的规则,要先用工具查,**绝对不要凭印象编造**。
  38. - 如果工具查不到、资料里也没有,就直接回答"资料里没有提到",不要猜。
  39. 可用工具:
  40. {tools}
  41. 请严格按下面两种格式之一回答,不要加任何多余的话:
  42. 格式 A(还需要用工具时):
  43. 思考:<你为什么需要这个工具>
  44. 行动:<工具名,必须是上面列出的名字之一>
  45. 行动输入:<给这个工具的输入,纯文本一行>
  46. 格式 B(已经能回答了):
  47. 思考:<你的推理>
  48. 最终答案:<给用户的回答,中文,简洁,需要时引用资料编号>
  49. 铁律:
  50. 1. 一次只调用一个工具。
  51. 2. "行动:"后面只能写工具名本身,不能带括号、不能带别的话。
  52. 3. **凡是涉及配载业务规则、图纸规则、文件读写约定这类问题,不管你自己觉不觉得知道答案,
  53. 都必须先调用【知识库检索】把资料拿到手,再回答。**
  54. 没检索过就直接说"资料里没有提到",是错的。
  55. 4. 只在"已经检索过、工具也返回了结果"之后,才可以直接给最终答案。
  56. 这时候还反复调用工具就是浪费。
  57. 5. 涉及具体数字(贝位、层号、坐标)时,必须以工具返回的结果为准,不要自己心算。
  58. """
  59. class ReActAgent:
  60. """最小可用的 ReAct 智能体。"""
  61. def __init__(
  62. self,
  63. llm: LLM,
  64. registry: ToolRegistry,
  65. memory: Memory | None = None,
  66. system_prompt: str | None = None,
  67. max_steps: int = 5,
  68. verbose: bool = True,
  69. ) -> None:
  70. self.llm = llm
  71. self.registry = registry
  72. self.memory = memory or Memory()
  73. self.max_steps = max_steps
  74. self.verbose = verbose
  75. self.system_prompt = (system_prompt or REACT_SYSTEM_PROMPT).format(
  76. tools=registry.describe()
  77. )
  78. self.context = ContextBuilder(self.system_prompt)
  79. # ------------------------------------------------------------------
  80. def run(self, question: str) -> Dict[str, Any]:
  81. """跑完一轮完整的思考-行动循环。
  82. 返回 {'answer': 最终答案, 'steps': [(思考, 行动, 观察), …], 'ok': 是否正常结束}
  83. """
  84. steps: List[Tuple[str, str, str]] = []
  85. self.memory.add("user", question)
  86. for i in range(1, self.max_steps + 1):
  87. if self.verbose:
  88. print(f"\n── 第 {i} 轮 ──")
  89. reply = self._think(question, steps)
  90. thought, action, action_input, final = self._parse(reply)
  91. # 情况一:模型觉得可以收工了
  92. if final:
  93. if self.verbose:
  94. print(f" 💭 思考:{thought}")
  95. print(f" ✅ 最终答案:{final}")
  96. self.memory.add("assistant", final)
  97. return {"answer": final, "steps": steps, "ok": True}
  98. # 情况二:模型要调工具
  99. if action:
  100. if self.verbose:
  101. print(f" 💭 思考:{thought}")
  102. print(f" 🔧 行动:{action}")
  103. print(f" 📥 输入:{action_input}")
  104. observation = self._use_tool(action, action_input)
  105. if self.verbose:
  106. preview = observation if len(observation) < 300 else observation[:300] + " …"
  107. print(f" 👀 观察:{preview}")
  108. steps.append((thought, action, observation))
  109. continue
  110. # 情况三:格式没按规矩来,提醒它一次
  111. if self.verbose:
  112. print(f" ⚠️ 格式不对,提醒模型重来。原始回复:{reply[:120]}")
  113. steps.append((thought, "", "(你的上一条回复不符合格式,请严格按 思考/行动/行动输入,或 思考/最终答案 来写)"))
  114. # 转 max_steps 轮还没收工,兜底
  115. fallback = "抱歉,我试了几种办法都没能得到确切答案。建议补充一下具体船名或图纸信息。"
  116. self.memory.add("assistant", fallback)
  117. return {"answer": fallback, "steps": steps, "ok": False}
  118. # ------------------------------------------------------------------
  119. def _think(self, question: str, steps: List[Tuple[str, str, str]]) -> str:
  120. """把当前状态拼成一封信,让模型回一句。"""
  121. scratchpad = ContextBuilder.build_agent_scratchpad(steps)
  122. user_block = f"问题:{question}"
  123. if scratchpad:
  124. user_block += f"\n\n你已经走过的步骤:\n{scratchpad}\n\n请继续。"
  125. messages = [
  126. {"role": "system", "content": self.system_prompt},
  127. {"role": "user", "content": user_block},
  128. ]
  129. return self.llm.chat(messages, temperature=0.0)
  130. # ------------------------------------------------------------------
  131. @staticmethod
  132. def _parse(reply: str) -> Tuple[str, str, str, str]:
  133. """从模型的一坨文字里,抠出"思考 / 行动 / 行动输入 / 最终答案"。
  134. 解析这件事看起来很土,但它是 Agent 最脆弱的一环——
  135. **模型只要少写一个冒号,整个循环就会卡住**。所以这里写得尽量宽松。
  136. """
  137. text = (reply or "").replace("\r\n", "\n").strip()
  138. def grab(label: str, stop: List[str]) -> str:
  139. """从 `label:` 后面一直取到下一个标签之前。"""
  140. pattern = rf"{label}\s*[::]\s*(.*?)(?=\n\s*(?:{'|'.join(stop)})\s*[::]|$)"
  141. match = re.search(pattern, text, re.S)
  142. return match.group(1).strip() if match else ""
  143. labels = ["思考", "行动", "行动输入", "最终答案", "观察"]
  144. thought = grab("思考", labels)
  145. final = grab("最终答案", labels)
  146. if final:
  147. return thought, "", "", final
  148. action = grab("行动", labels)
  149. action_input = grab("行动输入", labels)
  150. # 有些模型会把工具名和输入写在一行,例如 "行动:贝位计算器 01+03"
  151. # 这种情况下面会找不到"行动输入",就把工具名后面剩下的字当输入
  152. if action and not action_input:
  153. parts = action.split(maxsplit=1)
  154. if len(parts) == 2:
  155. action, action_input = parts[0], parts[1]
  156. # 去掉模型爱加的各种装饰
  157. action = re.sub(r"[`*\"'()()]", "", action).strip()
  158. action_input = re.sub(r"^[`*\"']|[`*\"']$", "", action_input).strip()
  159. return thought, action, action_input, ""
  160. # ------------------------------------------------------------------
  161. def _use_tool(self, name: str, query: str) -> str:
  162. """执行工具,并把任何异常变成一句"人话"回喂给模型。"""
  163. tool = self.registry.get(name)
  164. if tool is None:
  165. return (
  166. f"没有叫 '{name}' 的工具。可用工具只有:{', '.join(self.registry.names)}"
  167. )
  168. try:
  169. return tool.run(query)
  170. except Exception as exc: # noqa: BLE001 - 工具报错也要让循环继续
  171. return f"工具 '{name}' 执行出错:{exc}"