Files
AgentLab/backend/app/engine/state.py
T
ThearchyHelios f43f56abb7 把 Copilot 的思考显示出来:模型一直在想,只是被丢掉了
用户问能不能看到"正在理解需求、规划步骤"里面发生了什么。查下来是这样:
模型确实在想,reasoning 花了实打实的 token,但一路上被丢了三次。

1. langchain 的 ChatOpenAI **故意**不保留第三方的 reasoning_content——它的
   文档原话是 "Non-standard response fields added by third-party providers
   are not extracted or preserved",建议换 ChatDeepSeek 之类的专用包。可
   "用哪个供应商"在这个产品里是用户在设置页配的(DeepSeek / Qianfan /
   vLLM / OpenRouter 都可能),为每种装一个专用包不现实。在 factory 里
   子类化捞回 additional_kwargs,只加字段,不动 langchain 对正文的处理。

2. thinking_text 只认 Anthropic 的 thinking 块。补上兼容端点那一种。

3. 前端把一段思考的标题固定成开头 60 个字。思考是流式的,开头往往是
   "用户想要一个……"这类复述,之后几十秒里它一直在推进——固定显示开头
   等于把一段活的叙述冻在起点上,看着就像卡住了。改成:还在想的时候显示
   最新一句,想完了换回开头那句(那时它是一条历史记录)。

实测一次真实生成,60 秒里侧栏逐句变化:

    3s  可用数据源 bi
    9s  人工节点 approve 模式,审核
   18s  我们可以用 db_query__sales 执行 SELECT COUNT(*) FROM orders 等
   36s  更合理:rejected -> query_agent(重新
   45s  对于 bi 有 53 个对象,靠
   57s  (plan 到达,思考收尾,换回开头那句)

顺带运行期的 LLM 节点也跟着有了思考——走的是同一个 thinking_text,之前
同样只有 Anthropic 系才有。已验证流式合并后思考连成完整一段,不是只剩
最后一片。

tests/test_thinking.py 守两种形态、合并、以及"不产思考的模型不硬凑"。
check-decode 补 5 条守"还在想显示最新一句、想完了换开头"。
2026-09-18 09:55:42 +08:00

115 lines
4.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import operator
from typing import Annotated, Any, TypedDict
from langchain_core.messages import AnyMessage
from langgraph.graph import add_messages
def merge_dict(left: dict[str, Any] | None, right: dict[str, Any] | None) -> dict[str, Any]:
"""浅合并。并行分支各自写不同的 key 时不会互相覆盖。"""
out = dict(left or {})
out.update(right or {})
return out
def merge_usage(left: dict[str, Any] | None, right: dict[str, Any] | None) -> dict[str, Any]:
"""累加 token / 成本,其余字段取新值。"""
out = dict(left or {})
for key, value in (right or {}).items():
if isinstance(value, (int, float)) and isinstance(out.get(key), (int, float)):
out[key] = out[key] + value
else:
out[key] = value
return out
class GraphState(TypedDict, total=False):
"""所有节点共享的状态。
- messages:对话历史,agent 节点之间靠它传递上下文
- nodes:node_id -> 该节点的结构化输出,模板里用 {{ nodes.xxx.text }} 引用
- vars:用户显式写入的变量池
- output:output 节点收集的最终成果
"""
messages: Annotated[list[AnyMessage], add_messages]
input: dict[str, Any]
vars: Annotated[dict[str, Any], merge_dict]
nodes: Annotated[dict[str, Any], merge_dict]
output: Annotated[dict[str, Any], merge_dict]
usage: Annotated[dict[str, Any], merge_usage]
trail: Annotated[list[dict[str, Any]], operator.add]
# 循环计数器:node_id -> 已迭代次数,防止无限循环
loops: Annotated[dict[str, Any], merge_dict]
def template_context(state: GraphState) -> dict[str, Any]:
"""把状态摊平成模板/表达式可以引用的命名空间。"""
return {
"input": state.get("input") or {},
"nodes": state.get("nodes") or {},
"vars": state.get("vars") or {},
"output": state.get("output") or {},
"loops": state.get("loops") or {},
"usage": state.get("usage") or {},
"messages": [
{"role": getattr(m, "type", "?"), "content": _text_of(m)}
for m in (state.get("messages") or [])
],
"last_message": _text_of((state.get("messages") or [None])[-1]),
}
def _text_of(message: Any) -> str:
"""把 LangChain 的多模态 content 压成纯文本。"""
if message is None:
return ""
content = getattr(message, "content", message)
if isinstance(content, str):
return content
if isinstance(content, list):
parts: list[str] = []
for block in content:
if isinstance(block, str):
parts.append(block)
elif isinstance(block, dict):
if block.get("type") == "text":
parts.append(block.get("text", ""))
elif "text" in block:
parts.append(str(block["text"]))
return "".join(parts)
return str(content)
def message_text(message: Any) -> str:
return _text_of(message)
def thinking_text(message: Any) -> str:
"""抽出思考内容。
两种形态都要认,因为两边的模型都会产出思考,而界面上"它大致在想什么"
只有这一个来源:
- Anthropic 系:content 是块列表,思考混在里面(type == "thinking")。
正文提取必须跳过它们,但它本身很有价值,所以单独取出来。
- OpenAI 兼容系(DeepSeek / Qianfan / vLLM 等):思考走 delta 里的
reasoning_content。langchain 的 ChatOpenAI 明确不保留这个字段,
我们在 providers/factory.py 里子类化捞回了 additional_kwargs。
"""
extra = getattr(message, "additional_kwargs", None) or {}
reasoning = extra.get("reasoning_content") or extra.get("reasoning")
if isinstance(reasoning, str) and reasoning:
return reasoning
content = getattr(message, "content", message)
if not isinstance(content, list):
return ""
parts: list[str] = []
for block in content:
if isinstance(block, dict) and block.get("type") == "thinking":
parts.append(str(block.get("thinking") or ""))
return "".join(parts)