200字
Agent学习-Phase 3.5: CLI 交互与可观测性
2026-04-27
2026-04-27

前置说明:这是一个我自己学习Agent Harness开发的一个笔记,上传到这里纯属方便,这个项目叫Odd, 是通过分多个Phase来完成一个能够支持Tools MCP Skill 子agent 任务清单和上下文压缩的Agent框架。我会分多个Phase让AI完成代码,并阅读代码学习

至于为什么用Ado当头图,那当然是因为Ado统治世界

仓库在:https://github.com/GC-SHIRO/Odd.git

一句话总结

给 Agent 加上一个"操作台": 这次的更新加了main.py 让人类能在命令行里和 Agent 持续对话,同时看清 Agent 每一步在想什么、调了什么工具、结果如何

  • 这一次终于能看到交互效果了,真的很爽


效果图

阶段 3 的 Agent 能跑任务,但不能"聊天"

阶段 3 的 Agent.run(task) 是个"一次性任务执行器":

agent.run("列出当前目录文件")  # → 返回结果,对话结束
agent.run("再上一级看看")      # → 完全忘了上一句说了什么

每次 run() 都会新建一条消息链,[System, User任务],之前的历史全丢了。

如果用户想连续对话——比如先问"当前有什么文件",再说"把第一个文件读给我看看"——Agent 根本接不住,因为它没有上下文记忆


核心零件

1. 给 Agent 增加 chat() 方法(odd/agent.py

思路:把 ReAct 循环逻辑抽出来,让两种入口共用同一套核心

run(task) → 新建 [System, User] → _react_loop() → 返回结果
chat(input, history) → 复用 history → 追加 User → _react_loop() → 返回 (结果, 新history)

具体逻辑:

  • chat(user_input, messages=None)

  • 如果 messagesNone,新建一条带 System prompt 的空链

  • 把用户输入追加进去

  • 调用 _react_loop()

  • 返回 (结果, 更新后的消息链),调用方把新消息链存下来,下次再传进来

messages = None
while True:
    result, messages = agent.chat(user_input, messages)
    # messages 里现在有了 System + User + Assistant + Tool + ... 的完整历史

关键设计_react_loop原地修改 messages 的。这样 chat() 不需要做额外的拷贝,消息链在循环中自动增长。


2. 回调机制:让外部"看到" Agent 的内部过程

_react_loop() 跑在 Agent 内部,外部调用方不知道模型什么时候思考了、什么时候调了工具。我们需要一种可观测性机制。

方案对比:

方案

做法

优点

缺点

A

在 Agent 里直接 print()

最简单

污染库代码,调用方无法控制

B

继承 Agent 重写 _react_loop()

不改动原类

重复核心逻辑

C

回调参数

最小侵入,调用方自由决定

多两个参数

选了 方案 C,给 run()chat() 各加两个可选回调:

def chat(
    self,
    user_input: str,
    messages: Optional[List[Message]] = None,
    on_think: Optional[callable] = None,        # ← 模型回复后触发
    on_tool_result: Optional[callable] = None,  # ← 工具执行后触发
):
  • on_think(content, tool_calls, thinking):模型每产生一次回复就触发。thinking 是模型推理内容(如果 API 提供了的话)

  • on_tool_result(name, arguments, result):每个工具执行完触发,返回工具名、参数、执行结果

为什么不在 on_tool_result 之前加 on_tool_call

因为工具调用请求已经在 on_thinktool_calls 参数里暴露了,没必要再拆一个回调。保持接口最小。


3. 提取模型的"思考"内容(odd/models/

最初想在 on_think 里从 content 中解析 <think> 标签来显示思考过程。但这招对 Anthropic API 无效

原因:Anthropic 返回的是 content blocks 数组,思考内容在 type == "thinking" 的 block 里,根本不会出现在 content 文本中。

# Anthropic 返回结构
resp.content = [
    {"type": "thinking", "thinking": "我应该用 shell 工具..."},
    {"type": "text", "text": "我来帮你查一下"},
    {"type": "tool_use", "name": "shell", "input": {"command": "ls"}},
]

原来的解析代码只遍历了 texttool_usethinking block 被直接丢弃。

修复

  1. ChatResponse 新增 thinking: Optional[str] 字段

  2. AnthropicProvider.chat():遍历 blocks 时,遇到 thinking 就收集,遇到 redacted_thinking 就记 "[redacted thinking]"

  3. OpenAIProvider.chat():顺便兼容 OpenAI 的 reasoning 模型,提取 choice.message.reasoning_content

  4. on_think 回调增加第三个参数 thinkingmain.py 里优先显示它


4. CLI 入口(main.py

代码:

def main():
    parser = argparse.ArgumentParser(description="Odd Agent CLI")
    parser.add_argument(
        "--provider",
        default="anthropic",
        choices=["openai", "anthropic"],
        help="模型提供者 (默认: anthropic)",
    )
    args = parser.parse_args()

    # 延迟导入,避免无意义的环境变量检查
    from odd.agent import Agent
    from odd.tools.registry import registry

    # 导入内置工具以触发自动注册
    from odd.tools.builtin import shell, filesystem, fetch_url, python  # noqa: F401

    # 创建模型
    if args.provider == "openai":
        from odd.models.openai import OpenAIProvider

        model = OpenAIProvider()
    else:
        from odd.models.anthropic import AnthropicProvider

        model = AnthropicProvider()

    tools = registry.list_tools()
    agent = Agent(model=model, tools=tools)
    on_think, on_tool_result = make_callbacks()

    print("🤖 Odd Agent 已就绪")
    print(f"   提供者: {args.provider}")
    print(f"   工具: {', '.join(t.spec.name for t in tools) or '无'}")
    print("   输入 'exit' 或 'quit' 退出\n")

    messages = None
    while True:
        try:
            user_input = input("> ").strip()
        except (EOFError, KeyboardInterrupt):
            print("\n再见!")
            break

        if not user_input:
            continue
        if user_input.lower() in ("exit", "quit"):
            print("再见!")
            break

        try:
            result, messages = agent.chat(
                user_input,
                messages,
                on_think=on_think,
                on_tool_result=on_tool_result,
            )
            print(result)
        except Exception as exc:
            print(f"[错误] {exc}", file=sys.stderr)

整体结构很薄,只做四件事:

  1. 解析命令行参数--provider 选 openai / anthropic

  2. 加载工具import 内置工具模块触发自动注册

  3. 创建 Agent:按 provider 实例化对应模型,挂上全部工具

  4. 对话循环:读输入 → agent.chat(..., on_think=..., on_tool_result=...) → 打印结果

回调函数的输出设计:

> 列出当前目录
  💭 我应该使用 shell 工具执行 ls 命令来查看文件列表
  🤔 调用工具: shell({"command": "ls"})
  ✅ 工具 [shell] 完成: {"stdout": "main.py\nodd\n...", "s...
当前目录包含 main.py、odd、scripts ...
  • 💭:思考过程(截断到 120 字符,防刷屏)

  • 🤔:工具调用意图(截断到 80 字符)

  • :工具执行成功(截断到 60 字符)

  • :工具执行失败(显示完整错误信息)

缩进两个空格,和最终的顶格输出形成视觉层次。


学到的要点

  1. 状态外置 = 上下文记忆chat() 不自己持有历史,而是由调用方传入/接收。这样 Agent 本身保持无状态,同一实例可以被多个对话复用。

  2. 回调比继承更灵活:想让 Agent 支持可观测性,加回调参数是最小侵入的方式。继承重写虽然也行,但会复制核心逻辑,维护成本更高。

  3. 不同 API 的思考内容位置千差万别:OpenAI 的 reasoning 在 message.reasoning_content,Anthropic 的 thinking 在独立的 content block,还有模型直接把推理塞在 <think> 标签里。做可观测性时必须逐一适配。

  4. CLI 体验要分层:最终答案顶格输出,中间过程缩进显示。人类一眼就能区分"这是 Agent 在干活"和"这是 Agent 给我的答复"。

评论